projects: file explorer, ws improvements, i18n, and fs routing
Nightly Build / build (push) Successful in 6m47s
Nightly Build / build (push) Successful in 6m47s
Add project-files component with tree navigation. Extend UserFs with shared-folder resolution. Wire API routes for file browsing. Improve WS session lifecycle and project-board layout. Add i18n keys for projects and inbox across all locales.
This commit is contained in:
+118
-5
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
body::Bytes,
|
||||
extract::{Query, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
@@ -9,6 +10,7 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::sync::Arc;
|
||||
use core_api::user_fs::UserFs;
|
||||
use skald_core::skald::Skald;
|
||||
use skald_core::latex::CompileError;
|
||||
use skald_core::tools::fs as fs_tools;
|
||||
@@ -16,12 +18,80 @@ use super::ApiError;
|
||||
use super::guard::AuthUser;
|
||||
use super::require_context;
|
||||
|
||||
/// Upload body cap for `POST /api/file/upload` (same budget as chat attachments).
|
||||
pub const MAX_UPLOAD_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One row of a directory listing: name + agent path (round-trips through
|
||||
/// `/api/file`) + the metadata the explorer table shows. `size` is files-only;
|
||||
/// timestamps are RFC-3339 UTC (`None` when the filesystem can't provide them,
|
||||
/// e.g. no birth-time support) and formatted client-side.
|
||||
#[derive(Serialize)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub created_at: Option<String>,
|
||||
pub modified_at: Option<String>,
|
||||
}
|
||||
|
||||
fn fmt_ts(t: std::time::SystemTime) -> String {
|
||||
chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339()
|
||||
}
|
||||
|
||||
/// Reject a write when the caller's mount for this path is read-only
|
||||
/// (a shared-folder / project membership without `can_write`, or the docs
|
||||
/// tree). The container bind mount is the physical gate for in-container
|
||||
/// writes; the host-side HTTP API needs its own check.
|
||||
fn require_write(fs: &UserFs, agent: &str) -> Result<(), ApiError> {
|
||||
if fs.can_write_to(agent) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::forbidden(format!("read-only: {agent}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/files/dir?path=… — the immediate children of a directory (dirs
|
||||
/// first, then name), resolved and scoped exactly like `GET /api/file`.
|
||||
pub async fn list_dir(
|
||||
State(state): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Query(q): Query<FileQuery>,
|
||||
) -> Result<Json<Vec<DirEntry>>, ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let (abs, agent) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &q.path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
if !abs.is_dir() {
|
||||
return Err(ApiError::bad_request(format!("not a directory: {agent}")));
|
||||
}
|
||||
let mut entries: Vec<DirEntry> = Vec::new();
|
||||
for entry in std::fs::read_dir(&abs)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let md = entry.metadata().ok();
|
||||
let is_dir = md.as_ref().is_some_and(|m| m.is_dir());
|
||||
entries.push(DirEntry {
|
||||
path: format!("{agent}/{name}"),
|
||||
name,
|
||||
is_dir,
|
||||
size: md.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
|
||||
created_at: md.as_ref().and_then(|m| m.created().ok()).map(fmt_ts),
|
||||
modified_at: md.as_ref().and_then(|m| m.modified().ok()).map(fmt_ts),
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_dir.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
pub async fn list_files(
|
||||
State(state): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
@@ -231,6 +301,9 @@ pub struct SavePayload {
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePayload {
|
||||
pub path: String,
|
||||
/// When `true`, create a directory instead of an empty file.
|
||||
#[serde(default)]
|
||||
pub dir: bool,
|
||||
}
|
||||
|
||||
pub async fn create_file(
|
||||
@@ -239,15 +312,45 @@ pub async fn create_file(
|
||||
Json(body): Json<CreatePayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &body.path)
|
||||
let fs = ctx.fs.load();
|
||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
require_write(&fs, &display)?;
|
||||
if abs.exists() {
|
||||
return Err(anyhow::anyhow!("File already exists: {display}").into());
|
||||
}
|
||||
if body.dir {
|
||||
std::fs::create_dir_all(&abs)?;
|
||||
} else {
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&abs, "")?;
|
||||
}
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
/// POST /api/file/upload?path=… — raw request-body bytes written to `path`
|
||||
/// (create or replace), for binary uploads from the project explorer. The
|
||||
/// route caps the body at [`MAX_UPLOAD_BYTES`]; parent dirs are created.
|
||||
pub async fn upload_file(
|
||||
State(state): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Query(q): Query<FileQuery>,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let fs = ctx.fs.load();
|
||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &q.path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
require_write(&fs, &display)?;
|
||||
if abs.is_dir() {
|
||||
return Err(ApiError::bad_request(format!("is a directory: {display}")));
|
||||
}
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&abs, "")?;
|
||||
std::fs::write(&abs, &body)?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
@@ -257,8 +360,10 @@ pub async fn save_file(
|
||||
Json(body): Json<SavePayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &body.path)
|
||||
let fs = ctx.fs.load();
|
||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
require_write(&fs, &display)?;
|
||||
if !abs.exists() {
|
||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
||||
}
|
||||
@@ -283,6 +388,8 @@ pub async fn rename_file(
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
let (new_abs, new_disp) = fs_tools::resolve_view_path(fs.as_ref(), &body.new_path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
require_write(&fs, &old_disp)?;
|
||||
require_write(&fs, &new_disp)?;
|
||||
if !old_abs.exists() {
|
||||
return Err(anyhow::anyhow!("File not found: {old_disp}").into());
|
||||
}
|
||||
@@ -302,12 +409,18 @@ pub async fn delete_file(
|
||||
Query(q): Query<FileQuery>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &q.path)
|
||||
let fs = ctx.fs.load();
|
||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &q.path)
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
require_write(&fs, &display)?;
|
||||
if !abs.exists() {
|
||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
||||
}
|
||||
std::fs::remove_file(&abs)?;
|
||||
if abs.is_dir() {
|
||||
std::fs::remove_dir_all(&abs)?;
|
||||
} else {
|
||||
std::fs::remove_file(&abs)?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -203,8 +203,11 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/mcp-media/{file}", get(mcp_media::get_media))
|
||||
// Files
|
||||
.route("/files", get(files::list_files))
|
||||
.route("/files/dir", get(files::list_dir))
|
||||
.route("/file", get(files::get_file))
|
||||
.route("/file", post(files::create_file))
|
||||
.route("/file/upload", post(files::upload_file)
|
||||
.layer(DefaultBodyLimit::max(files::MAX_UPLOAD_BYTES)))
|
||||
.route("/file", put(files::save_file))
|
||||
.route("/file", patch(files::rename_file))
|
||||
.route("/file", delete(files::delete_file))
|
||||
|
||||
@@ -57,6 +57,9 @@ pub struct ProjectDetail {
|
||||
pub owner_name: String,
|
||||
pub is_owner: bool,
|
||||
pub can_write: bool,
|
||||
/// The agent path of the project folder (`projects/{owner_username}/{slug}`) —
|
||||
/// the explorer's root; round-trips through `/api/file*` endpoints.
|
||||
pub root_path: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub members: Vec<MemberView>,
|
||||
@@ -152,15 +155,22 @@ async fn remount(skald: &Skald, user_id: &str) {
|
||||
async fn detail(skald: &Skald, project: Project, caller: &str, can_write: bool) -> Result<ProjectDetail, ApiError> {
|
||||
let members = project_members::members(skald.db(), project.id).await?;
|
||||
let owner_name = user_label(skald, &project.owner_user_id).await;
|
||||
// The agent path keys on the owner's *username* (the mount segment), which
|
||||
// `owner_name` may not be (it's `display_name || username`).
|
||||
let owner_username = match users::get(skald.db(), &project.owner_user_id).await {
|
||||
Ok(Some(u)) => u.username,
|
||||
_ => project.owner_user_id.clone(),
|
||||
};
|
||||
Ok(ProjectDetail {
|
||||
is_owner: project.owner_user_id == caller,
|
||||
owner_name,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
slug: project.slug.clone(),
|
||||
description: project.description,
|
||||
owner_user_id: project.owner_user_id,
|
||||
can_write,
|
||||
root_path: format!("projects/{owner_username}/{}", project.slug),
|
||||
created_at: project.created_at,
|
||||
updated_at: project.updated_at,
|
||||
members: members.into_iter().map(Into::into).collect(),
|
||||
|
||||
+13
-3
@@ -428,10 +428,20 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
match event {
|
||||
Ok(ge) => {
|
||||
// Forward events for this connection's source.
|
||||
// ApprovalResolved is forwarded regardless of source so the
|
||||
// copilot can react to approvals resolved from other clients.
|
||||
// The inbox lifecycle events (approval/clarification/
|
||||
// elicitation requested+resolved) are forwarded regardless
|
||||
// of source: they carry no content — just ids — and let the
|
||||
// sidebar badge and inbox pages refresh live when any of
|
||||
// this user's sessions (chat, cron, background) raises or
|
||||
// settles a pending item.
|
||||
let forward = ge.source.as_deref() == Some(source.as_str())
|
||||
|| matches!(ge.event, ServerEvent::ApprovalResolved { .. });
|
||||
|| matches!(ge.event,
|
||||
ServerEvent::ApprovalRequested { .. }
|
||||
| ServerEvent::ApprovalResolved { .. }
|
||||
| ServerEvent::ClarificationRequested { .. }
|
||||
| ServerEvent::ClarificationResolved { .. }
|
||||
| ServerEvent::ElicitationRequested { .. }
|
||||
| ServerEvent::ElicitationResolved { .. });
|
||||
if !forward { continue; }
|
||||
debug!(event_type = ge.event.type_name(), "sending event to client");
|
||||
if socket.send(to_msg(&ge.event)).await.is_err() {
|
||||
|
||||
Reference in New Issue
Block a user