Release 0.1.1 #3
Generated
+1
-1
@@ -4172,7 +4172,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "skald"
|
name = "skald"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "skald"
|
name = "skald"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -108,6 +108,32 @@ impl UserFs {
|
|||||||
.find(|m| m.owner_username == owner_username && m.slug == slug)
|
.find(|m| m.owner_username == owner_username && m.slug == slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the user may **write** at this agent path: their home → always;
|
||||||
|
/// a shared-folder or project mount → the membership's `can_write` flag;
|
||||||
|
/// `docs/…` → never (read-only). A `shared/`/`projects/` mount the user is
|
||||||
|
/// not a member of → false (fail-closed, same as the read side). Purely
|
||||||
|
/// lexical: memory paths never reach here (classified earlier).
|
||||||
|
pub fn can_write_to(&self, agent_path: &str) -> bool {
|
||||||
|
let stripped = strip_home_prefix(agent_path);
|
||||||
|
let mut parts = stripped.splitn(2, ['/', '\\']);
|
||||||
|
match parts.next() {
|
||||||
|
Some("shared") => {
|
||||||
|
let rest = parts.next().unwrap_or("");
|
||||||
|
let name = rest.splitn(2, ['/', '\\']).next().unwrap_or("");
|
||||||
|
self.shared_mount(name).map(|m| m.can_write).unwrap_or(false)
|
||||||
|
}
|
||||||
|
Some("projects") => {
|
||||||
|
let rest = parts.next().unwrap_or("");
|
||||||
|
let mut seg = rest.splitn(3, ['/', '\\']);
|
||||||
|
let owner = seg.next().unwrap_or("");
|
||||||
|
let slug = seg.next().unwrap_or("");
|
||||||
|
self.project_mount(owner, slug).map(|m| m.can_write).unwrap_or(false)
|
||||||
|
}
|
||||||
|
Some("docs") => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The bind mounts for `docker create`: `(host, container, writable)`, home first.
|
/// The bind mounts for `docker create`: `(host, container, writable)`, home first.
|
||||||
pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> {
|
pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> {
|
||||||
let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)];
|
let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)];
|
||||||
|
|||||||
+118
-5
@@ -2,6 +2,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
|
body::Bytes,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
@@ -9,6 +10,7 @@ use axum::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use core_api::user_fs::UserFs;
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
use skald_core::latex::CompileError;
|
use skald_core::latex::CompileError;
|
||||||
use skald_core::tools::fs as fs_tools;
|
use skald_core::tools::fs as fs_tools;
|
||||||
@@ -16,12 +18,80 @@ use super::ApiError;
|
|||||||
use super::guard::AuthUser;
|
use super::guard::AuthUser;
|
||||||
use super::require_context;
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct FileEntry {
|
pub struct FileEntry {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub name: 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(
|
pub async fn list_files(
|
||||||
State(state): State<Arc<Skald>>,
|
State(state): State<Arc<Skald>>,
|
||||||
Extension(auth): Extension<AuthUser>,
|
Extension(auth): Extension<AuthUser>,
|
||||||
@@ -231,6 +301,9 @@ pub struct SavePayload {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreatePayload {
|
pub struct CreatePayload {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
/// When `true`, create a directory instead of an empty file.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_file(
|
pub async fn create_file(
|
||||||
@@ -239,15 +312,45 @@ pub async fn create_file(
|
|||||||
Json(body): Json<CreatePayload>,
|
Json(body): Json<CreatePayload>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let ctx = require_context(&state, &auth.user_id).await?;
|
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()))?;
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
require_write(&fs, &display)?;
|
||||||
if abs.exists() {
|
if abs.exists() {
|
||||||
return Err(anyhow::anyhow!("File already exists: {display}").into());
|
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() {
|
if let Some(parent) = abs.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(&abs, "")?;
|
std::fs::write(&abs, &body)?;
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +360,10 @@ pub async fn save_file(
|
|||||||
Json(body): Json<SavePayload>,
|
Json(body): Json<SavePayload>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let ctx = require_context(&state, &auth.user_id).await?;
|
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()))?;
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
require_write(&fs, &display)?;
|
||||||
if !abs.exists() {
|
if !abs.exists() {
|
||||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
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()))?;
|
.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)
|
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()))?;
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
require_write(&fs, &old_disp)?;
|
||||||
|
require_write(&fs, &new_disp)?;
|
||||||
if !old_abs.exists() {
|
if !old_abs.exists() {
|
||||||
return Err(anyhow::anyhow!("File not found: {old_disp}").into());
|
return Err(anyhow::anyhow!("File not found: {old_disp}").into());
|
||||||
}
|
}
|
||||||
@@ -302,12 +409,18 @@ pub async fn delete_file(
|
|||||||
Query(q): Query<FileQuery>,
|
Query(q): Query<FileQuery>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let ctx = require_context(&state, &auth.user_id).await?;
|
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()))?;
|
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||||
|
require_write(&fs, &display)?;
|
||||||
if !abs.exists() {
|
if !abs.exists() {
|
||||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,8 +203,11 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
.route("/mcp-media/{file}", get(mcp_media::get_media))
|
.route("/mcp-media/{file}", get(mcp_media::get_media))
|
||||||
// Files
|
// Files
|
||||||
.route("/files", get(files::list_files))
|
.route("/files", get(files::list_files))
|
||||||
|
.route("/files/dir", get(files::list_dir))
|
||||||
.route("/file", get(files::get_file))
|
.route("/file", get(files::get_file))
|
||||||
.route("/file", post(files::create_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", put(files::save_file))
|
||||||
.route("/file", patch(files::rename_file))
|
.route("/file", patch(files::rename_file))
|
||||||
.route("/file", delete(files::delete_file))
|
.route("/file", delete(files::delete_file))
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ pub struct ProjectDetail {
|
|||||||
pub owner_name: String,
|
pub owner_name: String,
|
||||||
pub is_owner: bool,
|
pub is_owner: bool,
|
||||||
pub can_write: 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 created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub members: Vec<MemberView>,
|
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> {
|
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 members = project_members::members(skald.db(), project.id).await?;
|
||||||
let owner_name = user_label(skald, &project.owner_user_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 {
|
Ok(ProjectDetail {
|
||||||
is_owner: project.owner_user_id == caller,
|
is_owner: project.owner_user_id == caller,
|
||||||
owner_name,
|
owner_name,
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
slug: project.slug,
|
slug: project.slug.clone(),
|
||||||
description: project.description,
|
description: project.description,
|
||||||
owner_user_id: project.owner_user_id,
|
owner_user_id: project.owner_user_id,
|
||||||
can_write,
|
can_write,
|
||||||
|
root_path: format!("projects/{owner_username}/{}", project.slug),
|
||||||
created_at: project.created_at,
|
created_at: project.created_at,
|
||||||
updated_at: project.updated_at,
|
updated_at: project.updated_at,
|
||||||
members: members.into_iter().map(Into::into).collect(),
|
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 {
|
match event {
|
||||||
Ok(ge) => {
|
Ok(ge) => {
|
||||||
// Forward events for this connection's source.
|
// Forward events for this connection's source.
|
||||||
// ApprovalResolved is forwarded regardless of source so the
|
// The inbox lifecycle events (approval/clarification/
|
||||||
// copilot can react to approvals resolved from other clients.
|
// 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())
|
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; }
|
if !forward { continue; }
|
||||||
debug!(event_type = ge.event.type_name(), "sending event to client");
|
debug!(event_type = ge.event.type_name(), "sending event to client");
|
||||||
if socket.send(to_msg(&ge.event)).await.is_err() {
|
if socket.send(to_msg(&ge.event)).await.is_err() {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) {
|
|||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
// Live refresh: the chat WS pushes `inbox-changed` when any of this user's
|
||||||
|
// sessions raises or settles a pending item — reload immediately if open.
|
||||||
|
this.__onInboxChanged = () => { if (this._open) this._loadInbox(); };
|
||||||
|
window.addEventListener('inbox-changed', this.__onInboxChanged);
|
||||||
window.addEventListener('llm-page-change', (e) => {
|
window.addEventListener('llm-page-change', (e) => {
|
||||||
this._open = e.detail.page === 'inbox';
|
this._open = e.detail.page === 'inbox';
|
||||||
this.style.display = this._open ? 'flex' : 'none';
|
this.style.display = this._open ? 'flex' : 'none';
|
||||||
@@ -35,11 +39,13 @@ export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) {
|
|||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this._stopPolling();
|
this._stopPolling();
|
||||||
|
window.removeEventListener('inbox-changed', this.__onInboxChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
_startPolling() {
|
_startPolling() {
|
||||||
this._stopPolling();
|
this._stopPolling();
|
||||||
this._pollTimer = setInterval(() => this._loadInbox(), 8000);
|
// Fallback only — pushes via `inbox-changed` keep the page fresh.
|
||||||
|
this._pollTimer = setInterval(() => this._loadInbox(), 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopPolling() {
|
_stopPolling() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export class ProjectsPage extends LightElement {
|
|||||||
_open: { state: true },
|
_open: { state: true },
|
||||||
_view: { state: true },
|
_view: { state: true },
|
||||||
_projectId: { state: true },
|
_projectId: { state: true },
|
||||||
|
_tab: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -15,6 +16,7 @@ export class ProjectsPage extends LightElement {
|
|||||||
this._open = false;
|
this._open = false;
|
||||||
this._view = 'list';
|
this._view = 'list';
|
||||||
this._projectId = null;
|
this._projectId = null;
|
||||||
|
this._tab = 'files';
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -24,9 +26,25 @@ export class ProjectsPage extends LightElement {
|
|||||||
this._open = open;
|
this._open = open;
|
||||||
this.style.display = open ? 'flex' : 'none';
|
this.style.display = open ? 'flex' : 'none';
|
||||||
if (open) {
|
if (open) {
|
||||||
const { view, id } = this._parseHash();
|
const { view, id, tab } = this._parseHash();
|
||||||
this._view = view;
|
this._view = view;
|
||||||
this._projectId = id;
|
this._projectId = id;
|
||||||
|
this._tab = tab;
|
||||||
|
this._loadCurrent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
// Back/forward (or manual edit) between board tabs: same project → just
|
||||||
|
// switch the tab, no reload; anything else → re-sync from the hash.
|
||||||
|
if (!this._open || !location.hash.startsWith('#projects')) return;
|
||||||
|
const { view, id, tab } = this._parseHash();
|
||||||
|
if (view === 'board' && this._view === 'board' && id === this._projectId) {
|
||||||
|
this._tab = tab;
|
||||||
|
this.querySelector('project-board-section')?.setTab(tab);
|
||||||
|
} else {
|
||||||
|
this._view = view;
|
||||||
|
this._projectId = id;
|
||||||
|
this._tab = tab;
|
||||||
this._loadCurrent();
|
this._loadCurrent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -40,9 +58,10 @@ export class ProjectsPage extends LightElement {
|
|||||||
_parseHash() {
|
_parseHash() {
|
||||||
const parts = location.hash.slice(1).split('/');
|
const parts = location.hash.slice(1).split('/');
|
||||||
if (parts[0] === 'projects' && parts[1] && /^\d+$/.test(parts[1])) {
|
if (parts[0] === 'projects' && parts[1] && /^\d+$/.test(parts[1])) {
|
||||||
return { view: 'board', id: parseInt(parts[1], 10) };
|
const tab = parts[2] === 'sharing' ? 'sharing' : 'files';
|
||||||
|
return { view: 'board', id: parseInt(parts[1], 10), tab };
|
||||||
}
|
}
|
||||||
return { view: 'list', id: null };
|
return { view: 'list', id: null, tab: 'files' };
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadCurrent() {
|
_loadCurrent() {
|
||||||
@@ -50,7 +69,7 @@ export class ProjectsPage extends LightElement {
|
|||||||
if (this._view === 'list') {
|
if (this._view === 'list') {
|
||||||
this.querySelector('project-list-section')?.load();
|
this.querySelector('project-list-section')?.load();
|
||||||
} else {
|
} else {
|
||||||
this.querySelector('project-board-section')?.load(this._projectId);
|
this.querySelector('project-board-section')?.load(this._projectId, this._tab);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,9 +77,10 @@ export class ProjectsPage extends LightElement {
|
|||||||
_navigateToBoard(id) {
|
_navigateToBoard(id) {
|
||||||
this._view = 'board';
|
this._view = 'board';
|
||||||
this._projectId = id;
|
this._projectId = id;
|
||||||
|
this._tab = 'files';
|
||||||
history.pushState({ page: 'projects', id }, '', `#projects/${id}`);
|
history.pushState({ page: 'projects', id }, '', `#projects/${id}`);
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
this.querySelector('project-board-section')?.load(id);
|
this.querySelector('project-board-section')?.load(id, 'files');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +93,12 @@ export class ProjectsPage extends LightElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onTabChange(tab) {
|
||||||
|
this._tab = tab;
|
||||||
|
const hash = tab === 'sharing' ? `#projects/${this._projectId}/sharing` : `#projects/${this._projectId}`;
|
||||||
|
history.pushState({ page: 'projects', id: this._projectId }, '', hash);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this._open) return nothing;
|
if (!this._open) return nothing;
|
||||||
return html`
|
return html`
|
||||||
@@ -83,6 +109,7 @@ export class ProjectsPage extends LightElement {
|
|||||||
` : html`
|
` : html`
|
||||||
<project-board-section
|
<project-board-section
|
||||||
@project-back=${() => this._navigateToList()}
|
@project-back=${() => this._navigateToList()}
|
||||||
|
@project-tab-change=${e => this._onTabChange(e.detail.tab)}
|
||||||
></project-board-section>
|
></project-board-section>
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { LightElement } from '../../lib/base.js';
|
import { LightElement } from '../../lib/base.js';
|
||||||
import { t } from '../../lib/i18n.js';
|
import { t } from '../../lib/i18n.js';
|
||||||
|
import { ProjectFilesPanel } from './project-files.js';
|
||||||
|
|
||||||
/// A project's detail page: header + description, a sharing panel (member picker with
|
/// A project's detail page: header + description, then two tabs — **Files** (a
|
||||||
/// read/write, mirroring the shared-folders UI), Open chat, and a Files section (the
|
/// live explorer over the project folder, `<project-files-panel>`) and
|
||||||
/// future primary surface — a file explorer over the project folder). No ticket board.
|
/// **Sharing** (member picker with read/write, mirroring the shared-folders UI).
|
||||||
export class ProjectBoardSection extends LightElement {
|
export class ProjectBoardSection extends LightElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
_project: { state: true },
|
_project: { state: true },
|
||||||
_users: { state: true },
|
_users: { state: true },
|
||||||
_add: { state: true },
|
_add: { state: true },
|
||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
|
_tab: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -20,6 +22,7 @@ export class ProjectBoardSection extends LightElement {
|
|||||||
this._add = { user_id: '', can_write: false };
|
this._add = { user_id: '', can_write: false };
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._projectId = null;
|
this._projectId = null;
|
||||||
|
this._tab = 'files';
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -33,10 +36,11 @@ export class ProjectBoardSection extends LightElement {
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(projectId) {
|
async load(projectId, tab) {
|
||||||
this._projectId = projectId;
|
this._projectId = projectId;
|
||||||
this._project = null;
|
this._project = null;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
|
this._tab = tab === 'sharing' ? 'sharing' : 'files';
|
||||||
try {
|
try {
|
||||||
const [projRes, usersRes] = await Promise.all([
|
const [projRes, usersRes] = await Promise.all([
|
||||||
fetch(`/api/projects/${projectId}`),
|
fetch(`/api/projects/${projectId}`),
|
||||||
@@ -114,6 +118,19 @@ export class ProjectBoardSection extends LightElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switch the visible tab without reloading (host back/forward sync).
|
||||||
|
setTab(tab) {
|
||||||
|
this._tab = tab === 'sharing' ? 'sharing' : 'files';
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectTab(tab) {
|
||||||
|
if (tab === this._tab) return;
|
||||||
|
this._tab = tab;
|
||||||
|
this.dispatchEvent(new CustomEvent('project-tab-change', {
|
||||||
|
detail: { tab }, bubbles: true, composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
_back() {
|
_back() {
|
||||||
this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true }));
|
||||||
}
|
}
|
||||||
@@ -203,16 +220,19 @@ export class ProjectBoardSection extends LightElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderFilesPanel() {
|
_renderTabs() {
|
||||||
// The file explorer is the future primary surface (a directory listing endpoint over
|
const tab = (id, icon, label) => html`
|
||||||
// the project folder is a follow-on). For now, the chat's agent works in the folder.
|
<li class="nav-item">
|
||||||
|
<button class="nav-link ${this._tab === id ? 'active' : ''}" @click=${() => this._selectTab(id)}>
|
||||||
|
<i class="bi ${icon} me-1"></i>${label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
return html`
|
return html`
|
||||||
<div class="card mb-3">
|
<ul class="nav nav-tabs px-3">
|
||||||
<div class="card-body text-center text-muted py-4">
|
${tab('files', 'bi-folder2-open', t('projects.tabs.files'))}
|
||||||
<i class="bi bi-folder2-open" style="font-size:1.6rem"></i>
|
${tab('sharing', 'bi-people', t('projects.tabs.sharing'))}
|
||||||
<p class="mb-0 mt-2" style="font-size:0.9rem">${t('projects.files.placeholder')}</p>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +270,20 @@ export class ProjectBoardSection extends LightElement {
|
|||||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
|
|
||||||
|
${this._project.description ? html`
|
||||||
|
<p class="text-muted px-3 pt-2 mb-1" style="font-size:0.9rem">${this._project.description}</p>
|
||||||
|
` : nothing}
|
||||||
|
|
||||||
|
${this._renderTabs()}
|
||||||
|
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
${this._project.description
|
<project-files-panel .project=${this._project}
|
||||||
? html`<p class="text-muted" style="font-size:0.9rem">${this._project.description}</p>`
|
style=${this._tab === 'files' ? '' : 'display:none'}></project-files-panel>
|
||||||
: nothing}
|
${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
|
||||||
${this._renderFilesPanel()}
|
|
||||||
${this._renderSharePanel()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customElements.define('project-files-panel', ProjectFilesPanel);
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { html, nothing } from 'lit';
|
||||||
|
import { LightElement } from '../../lib/base.js';
|
||||||
|
import { t } from '../../lib/i18n.js';
|
||||||
|
import { fileWatcher } from '../../lib/file-watcher.js';
|
||||||
|
|
||||||
|
/// The Files tab of a project board: a live explorer over the project folder.
|
||||||
|
///
|
||||||
|
/// One directory at a time (`GET /api/files/dir`); clicking a folder navigates
|
||||||
|
/// into it, clicking a file opens it in the existing viewer (`window.openFile`).
|
||||||
|
/// The breadcrumb is rooted at the project folder (shown as `/`). The listing
|
||||||
|
/// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher`
|
||||||
|
/// singleton) pushes a `changed` event for the open directory whenever another
|
||||||
|
/// member — or the agent, from inside its container — creates/modifies/removes
|
||||||
|
/// a file in it. Write actions (new folder, upload, rename, delete) are offered
|
||||||
|
/// only to members with `can_write` and are gated server-side too.
|
||||||
|
export class ProjectFilesPanel extends LightElement {
|
||||||
|
static properties = {
|
||||||
|
project: { attribute: false },
|
||||||
|
_rel: { state: true },
|
||||||
|
_entries: { state: true },
|
||||||
|
_loading: { state: true },
|
||||||
|
_error: { state: true },
|
||||||
|
_busy: { state: true },
|
||||||
|
_modal: { state: true },
|
||||||
|
_drag: { state: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.project = null;
|
||||||
|
this._rel = ''; // path relative to the project root ('' = root)
|
||||||
|
this._entries = null;
|
||||||
|
this._loading = false;
|
||||||
|
this._error = null;
|
||||||
|
this._busy = false;
|
||||||
|
this._modal = null; // { mode: 'mkdir'|'rename', name, target? }
|
||||||
|
this._drag = false;
|
||||||
|
this._unwatch = null;
|
||||||
|
this._reloadTimer = null;
|
||||||
|
this._onChanged = () => this._scheduleReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
willUpdate(changed) {
|
||||||
|
// (Re)open the root only when the project itself changes — a refetch of the
|
||||||
|
// same project (member edits) must not reset the current folder.
|
||||||
|
if (changed.has('project')) {
|
||||||
|
const prev = changed.get('project');
|
||||||
|
if (this.project?.root_path && this.project.root_path !== prev?.root_path) {
|
||||||
|
this._open('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._unwatch?.();
|
||||||
|
clearTimeout(this._reloadTimer);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dirPath() {
|
||||||
|
const root = this.project?.root_path ?? '';
|
||||||
|
return this._rel ? `${root}/${this._rel}` : root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _open(rel) {
|
||||||
|
this._unwatch?.();
|
||||||
|
this._unwatch = null;
|
||||||
|
this._rel = rel;
|
||||||
|
this._error = null;
|
||||||
|
await this._load();
|
||||||
|
// Live updates for the open directory (best-effort: a dead watcher just
|
||||||
|
// means manual refresh; auto-reconnect + re-subscribe are handled inside).
|
||||||
|
try {
|
||||||
|
this._unwatch = await fileWatcher.watch(this._dirPath(), this._onChanged);
|
||||||
|
} catch { this._unwatch = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleReload() {
|
||||||
|
clearTimeout(this._reloadTimer);
|
||||||
|
this._reloadTimer = setTimeout(() => this._load(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _load() {
|
||||||
|
if (!this.project?.root_path) return;
|
||||||
|
this._loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
this._entries = await res.json();
|
||||||
|
this._error = null;
|
||||||
|
} catch (e) {
|
||||||
|
this._error = e.message;
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_enter(entry) {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
this._open(this._rel ? `${this._rel}/${entry.name}` : entry.name);
|
||||||
|
} else {
|
||||||
|
window.openFile(entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_goTo(index) {
|
||||||
|
// -1 = project root, otherwise the segment index to land on.
|
||||||
|
const segs = this._rel ? this._rel.split('/') : [];
|
||||||
|
this._open(index < 0 ? '' : segs.slice(0, index + 1).join('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_openModal(mode, target = null) {
|
||||||
|
this._modal = { mode, name: target?.name ?? '', target };
|
||||||
|
this.updateComplete.then(() => this.querySelector('.pf-modal-input')?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
async _submitModal(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = (this._modal?.name ?? '').trim();
|
||||||
|
if (!name || name.includes('/') || name.includes('\\')) {
|
||||||
|
this._error = t('projects.files.error.name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._busy = true;
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (this._modal.mode === 'mkdir') {
|
||||||
|
res = await fetch('/api/file', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: `${this._dirPath()}/${name}`, dir: true }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch('/api/file', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ old_path: this._modal.target.path, new_path: `${this._dirPath()}/${name}` }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
this._modal = null;
|
||||||
|
await this._load();
|
||||||
|
} catch (err) {
|
||||||
|
this._error = err.message;
|
||||||
|
} finally {
|
||||||
|
this._busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _remove(entry) {
|
||||||
|
const key = entry.is_dir ? 'projects.files.confirm.delete_dir' : 'projects.files.confirm.delete_file';
|
||||||
|
if (!confirm(t(key, { name: entry.name }))) return;
|
||||||
|
this._busy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/file?path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
await this._load();
|
||||||
|
} catch (e) {
|
||||||
|
this._error = e.message;
|
||||||
|
} finally {
|
||||||
|
this._busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uploadFiles(files) {
|
||||||
|
if (!files?.length) return;
|
||||||
|
this._busy = true;
|
||||||
|
this._error = null;
|
||||||
|
try {
|
||||||
|
for (const f of files) {
|
||||||
|
const target = `${this._dirPath()}/${f.name}`;
|
||||||
|
const res = await fetch(`/api/file/upload?path=${encodeURIComponent(target)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: f,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${f.name}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
// The watcher will also fire; reload now in case it is down.
|
||||||
|
await this._load();
|
||||||
|
} catch (e) {
|
||||||
|
this._error = e.message;
|
||||||
|
} finally {
|
||||||
|
this._busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickFiles() {
|
||||||
|
this.querySelector('.pf-file-input')?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_renderBreadcrumb() {
|
||||||
|
const segs = this._rel ? this._rel.split('/') : [];
|
||||||
|
return html`
|
||||||
|
<nav class="d-flex align-items-center flex-wrap" aria-label="breadcrumb"
|
||||||
|
style="--bs-breadcrumb-divider: '/';">
|
||||||
|
<ol class="breadcrumb mb-0" style="font-size:0.9rem">
|
||||||
|
<li class="breadcrumb-item ${segs.length === 0 ? 'active' : ''}">
|
||||||
|
${segs.length === 0
|
||||||
|
? html`<span title=${this.project.root_path}><i class="bi bi-hdd me-1"></i>/</span>`
|
||||||
|
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(-1); }}
|
||||||
|
title=${this.project.root_path}><i class="bi bi-hdd me-1"></i>/</a>`}
|
||||||
|
</li>
|
||||||
|
${segs.map((s, i) => html`
|
||||||
|
<li class="breadcrumb-item ${i === segs.length - 1 ? 'active' : ''}">
|
||||||
|
${i === segs.length - 1
|
||||||
|
? html`<span>${s}</span>`
|
||||||
|
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(i); }}>${s}</a>`}
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderToolbar() {
|
||||||
|
const canWrite = !!this.project?.can_write;
|
||||||
|
return html`
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
${this._renderBreadcrumb()}
|
||||||
|
<div class="ms-auto d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title=${t('projects.files.refresh')}
|
||||||
|
?disabled=${this._loading} @click=${() => this._load()}>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
${canWrite ? html`
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
|
||||||
|
@click=${() => this._openModal('mkdir')}>
|
||||||
|
<i class="bi bi-folder-plus me-1"></i>${t('projects.files.btn.new_folder')}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" ?disabled=${this._busy}
|
||||||
|
@click=${() => this._pickFiles()}>
|
||||||
|
${this._busy
|
||||||
|
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('projects.files.uploading')}`
|
||||||
|
: html`<i class="bi bi-upload me-1"></i>${t('projects.files.btn.upload')}`}
|
||||||
|
</button>
|
||||||
|
<input type="file" class="pf-file-input" multiple hidden
|
||||||
|
@change=${e => { this._uploadFiles([...e.target.files]); e.target.value = ''; }} />
|
||||||
|
` : nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_iconFor(entry) {
|
||||||
|
if (entry.is_dir) return 'bi-folder-fill text-warning';
|
||||||
|
const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : '';
|
||||||
|
const map = {
|
||||||
|
png: 'bi-file-image', jpg: 'bi-file-image', jpeg: 'bi-file-image',
|
||||||
|
gif: 'bi-file-image', webp: 'bi-file-image', svg: 'bi-file-image',
|
||||||
|
pdf: 'bi-file-pdf',
|
||||||
|
md: 'bi-file-text', txt: 'bi-file-text', tex: 'bi-file-text', latex: 'bi-file-text',
|
||||||
|
js: 'bi-file-code', ts: 'bi-file-code', py: 'bi-file-code', rs: 'bi-file-code',
|
||||||
|
json: 'bi-file-code', html: 'bi-file-code', css: 'bi-file-code', sh: 'bi-file-code',
|
||||||
|
zip: 'bi-file-zip', gz: 'bi-file-zip', tar: 'bi-file-zip',
|
||||||
|
mp3: 'bi-file-music', wav: 'bi-file-music', ogg: 'bi-file-music',
|
||||||
|
mp4: 'bi-file-play', mov: 'bi-file-play', webm: 'bi-file-play',
|
||||||
|
doc: 'bi-file-word', docx: 'bi-file-word',
|
||||||
|
xls: 'bi-file-excel', xlsx: 'bi-file-excel', csv: 'bi-file-excel',
|
||||||
|
};
|
||||||
|
return map[ext] ?? 'bi-file-earmark';
|
||||||
|
}
|
||||||
|
|
||||||
|
_fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d) ? '—' : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
_fmtSize(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRow(entry) {
|
||||||
|
const canWrite = !!this.project?.can_write;
|
||||||
|
return html`
|
||||||
|
<tr style="cursor:pointer" @click=${() => this._enter(entry)}>
|
||||||
|
<td style="width:2rem"><i class="bi ${this._iconFor(entry)}"></i></td>
|
||||||
|
<td style="word-break:break-all">${entry.name}</td>
|
||||||
|
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.created_at)}</td>
|
||||||
|
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.modified_at)}</td>
|
||||||
|
<td class="text-muted text-end text-nowrap" style="font-size:0.82rem">${entry.is_dir ? '—' : this._fmtSize(entry.size)}</td>
|
||||||
|
${canWrite ? html`
|
||||||
|
<td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
|
||||||
|
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('projects.files.action.rename')}
|
||||||
|
@click=${() => this._openModal('rename', entry)}>
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-link text-danger p-0" title=${t('projects.files.action.delete')}
|
||||||
|
?disabled=${this._busy} @click=${() => this._remove(entry)}>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
` : nothing}
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTable() {
|
||||||
|
const canWrite = !!this.project?.can_write;
|
||||||
|
if (!this._entries) {
|
||||||
|
return html`<div class="text-center py-4"><span class="spinner-border spinner-border-sm text-primary"></span></div>`;
|
||||||
|
}
|
||||||
|
if (this._entries.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-folder2-open" style="font-size:1.4rem"></i>
|
||||||
|
<p class="mb-0 mt-2" style="font-size:0.88rem">${t('projects.files.empty')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<table class="table table-sm table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>${t('projects.files.col.name')}</th>
|
||||||
|
<th style="width:9.5rem">${t('projects.files.col.created')}</th>
|
||||||
|
<th style="width:9.5rem">${t('projects.files.col.modified')}</th>
|
||||||
|
<th class="text-end" style="width:5.5rem">${t('projects.files.col.size')}</th>
|
||||||
|
${canWrite ? html`<th style="width:4.5rem"></th>` : nothing}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${this._entries.map(e => this._renderRow(e))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderModal() {
|
||||||
|
if (!this._modal) return nothing;
|
||||||
|
const isMkdir = this._modal.mode === 'mkdir';
|
||||||
|
return html`
|
||||||
|
<div class="agent-dialog-backdrop"
|
||||||
|
@click=${e => { if (e.target === e.currentTarget) this._modal = null; }}>
|
||||||
|
<div class="agent-dialog">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||||
|
<i class="bi ${isMkdir ? 'bi-folder-plus' : 'bi-pencil'}"></i>
|
||||||
|
<span style="font-weight:600">
|
||||||
|
${isMkdir ? t('projects.files.modal.mkdir') : t('projects.files.modal.rename', { name: this._modal.target.name })}
|
||||||
|
</span>
|
||||||
|
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
|
||||||
|
@click=${() => this._modal = null}>
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form @submit=${e => this._submitModal(e)}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.files.modal.name')}</label>
|
||||||
|
<input type="text" class="form-control form-control-sm pf-modal-input" required
|
||||||
|
.value=${this._modal.name}
|
||||||
|
@input=${e => this._modal = { ...this._modal, name: e.target.value }} />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
@click=${() => this._modal = null}>${t('projects.modal.cancel')}</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._busy}>
|
||||||
|
<i class="bi bi-check-lg me-1"></i>${isMkdir ? t('projects.modal.create') : t('projects.modal.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.project?.root_path) return nothing;
|
||||||
|
const canWrite = !!this.project?.can_write;
|
||||||
|
return html`
|
||||||
|
<div class="card ${this._drag ? 'border-primary' : ''}"
|
||||||
|
@dragover=${e => { if (canWrite) { e.preventDefault(); this._drag = true; } }}
|
||||||
|
@dragleave=${() => this._drag = false}
|
||||||
|
@drop=${e => { e.preventDefault(); this._drag = false; if (canWrite) this._uploadFiles([...e.dataTransfer.files]); }}>
|
||||||
|
<div class="card-body">
|
||||||
|
${this._renderToolbar()}
|
||||||
|
${this._error ? html`
|
||||||
|
<div class="alert alert-danger py-2 mb-2" style="font-size:0.85rem">${this._error}</div>
|
||||||
|
` : nothing}
|
||||||
|
${this._drag ? html`
|
||||||
|
<div class="text-center text-primary py-3" style="font-size:0.9rem">
|
||||||
|
<i class="bi bi-cloud-arrow-up me-1"></i>${t('projects.files.drop')}
|
||||||
|
</div>
|
||||||
|
` : this._renderTable()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this._renderModal()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,9 +112,12 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
|||||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||||
this._applyPage(page);
|
this._applyPage(page);
|
||||||
}, 0);
|
}, 0);
|
||||||
// Poll inbox count independently of whether the page is open.
|
// Poll inbox count independently of whether the page is open. The 60 s
|
||||||
|
// interval is only a fallback: `inbox-changed` (pushed over the chat WS
|
||||||
|
// when any session raises/settles a pending item) refreshes it live.
|
||||||
this._pollInbox();
|
this._pollInbox();
|
||||||
this._pollTimer = setInterval(() => this._pollInbox(), 10000);
|
this._pollTimer = setInterval(() => this._pollInbox(), 60000);
|
||||||
|
window.addEventListener('inbox-changed', () => this._pollInbox());
|
||||||
this._loadCollapsed();
|
this._loadCollapsed();
|
||||||
this._loadDebugMode();
|
this._loadDebugMode();
|
||||||
this._loadRecentProjects();
|
this._loadRecentProjects();
|
||||||
|
|||||||
+20
-1
@@ -221,7 +221,26 @@ export default {
|
|||||||
'projects.share.access.write': 'Write',
|
'projects.share.access.write': 'Write',
|
||||||
'projects.share.access.readonly': 'Read-only',
|
'projects.share.access.readonly': 'Read-only',
|
||||||
'projects.share.access.readwrite':'Read & write',
|
'projects.share.access.readwrite':'Read & write',
|
||||||
'projects.files.placeholder': 'The project files live here. Open the chat to work in this folder — a file explorer is coming.',
|
'projects.tabs.files': 'Files',
|
||||||
|
'projects.tabs.sharing': 'Sharing',
|
||||||
|
'projects.files.col.name': 'Name',
|
||||||
|
'projects.files.col.created': 'Created',
|
||||||
|
'projects.files.col.modified': 'Modified',
|
||||||
|
'projects.files.col.size': 'Size',
|
||||||
|
'projects.files.empty': 'This folder is empty.',
|
||||||
|
'projects.files.refresh': 'Refresh',
|
||||||
|
'projects.files.drop': 'Drop files here to upload',
|
||||||
|
'projects.files.btn.new_folder': 'New folder',
|
||||||
|
'projects.files.btn.upload': 'Upload',
|
||||||
|
'projects.files.uploading': 'Uploading…',
|
||||||
|
'projects.files.action.rename': 'Rename',
|
||||||
|
'projects.files.action.delete': 'Delete',
|
||||||
|
'projects.files.confirm.delete_file': 'Delete "{name}"?',
|
||||||
|
'projects.files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?',
|
||||||
|
'projects.files.modal.mkdir': 'New folder',
|
||||||
|
'projects.files.modal.rename': 'Rename "{name}"',
|
||||||
|
'projects.files.modal.name': 'Name',
|
||||||
|
'projects.files.error.name': 'Enter a valid name (no slashes).',
|
||||||
|
|
||||||
// ── Project detail ──────────────────────────────────────────────────────────
|
// ── Project detail ──────────────────────────────────────────────────────────
|
||||||
'project_board.back': 'Projects',
|
'project_board.back': 'Projects',
|
||||||
|
|||||||
+20
-1
@@ -221,7 +221,26 @@ export default {
|
|||||||
'projects.share.access.write': 'Écriture',
|
'projects.share.access.write': 'Écriture',
|
||||||
'projects.share.access.readonly': 'Lecture seule',
|
'projects.share.access.readonly': 'Lecture seule',
|
||||||
'projects.share.access.readwrite':'Lecture et écriture',
|
'projects.share.access.readwrite':'Lecture et écriture',
|
||||||
'projects.files.placeholder': 'Les fichiers du projet vivent ici. Ouvrez la discussion pour travailler dans ce dossier — un explorateur de fichiers arrive bientôt.',
|
'projects.tabs.files': 'Fichiers',
|
||||||
|
'projects.tabs.sharing': 'Partage',
|
||||||
|
'projects.files.col.name': 'Nom',
|
||||||
|
'projects.files.col.created': 'Création',
|
||||||
|
'projects.files.col.modified': 'Modification',
|
||||||
|
'projects.files.col.size': 'Taille',
|
||||||
|
'projects.files.empty': 'Ce dossier est vide.',
|
||||||
|
'projects.files.refresh': 'Actualiser',
|
||||||
|
'projects.files.drop': 'Déposez les fichiers ici pour les envoyer',
|
||||||
|
'projects.files.btn.new_folder': 'Nouveau dossier',
|
||||||
|
'projects.files.btn.upload': 'Envoyer',
|
||||||
|
'projects.files.uploading': 'Envoi…',
|
||||||
|
'projects.files.action.rename': 'Renommer',
|
||||||
|
'projects.files.action.delete': 'Supprimer',
|
||||||
|
'projects.files.confirm.delete_file': 'Supprimer « {name} » ?',
|
||||||
|
'projects.files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?',
|
||||||
|
'projects.files.modal.mkdir': 'Nouveau dossier',
|
||||||
|
'projects.files.modal.rename': 'Renommer « {name} »',
|
||||||
|
'projects.files.modal.name': 'Nom',
|
||||||
|
'projects.files.error.name': 'Saisissez un nom valide (sans barres obliques).',
|
||||||
|
|
||||||
// ── Détail du projet ────────────────────────────────────────────────────────
|
// ── Détail du projet ────────────────────────────────────────────────────────
|
||||||
'project_board.back': 'Projets',
|
'project_board.back': 'Projets',
|
||||||
|
|||||||
+20
-1
@@ -245,7 +245,26 @@ export default {
|
|||||||
'projects.share.access.write': 'Scrittura',
|
'projects.share.access.write': 'Scrittura',
|
||||||
'projects.share.access.readonly': 'Sola lettura',
|
'projects.share.access.readonly': 'Sola lettura',
|
||||||
'projects.share.access.readwrite':'Lettura e scrittura',
|
'projects.share.access.readwrite':'Lettura e scrittura',
|
||||||
'projects.files.placeholder': 'Qui vivono i file del progetto. Apri la chat per lavorare in questa cartella — un file explorer è in arrivo.',
|
'projects.tabs.files': 'File',
|
||||||
|
'projects.tabs.sharing': 'Condivisione',
|
||||||
|
'projects.files.col.name': 'Nome',
|
||||||
|
'projects.files.col.created': 'Creazione',
|
||||||
|
'projects.files.col.modified': 'Ultima modifica',
|
||||||
|
'projects.files.col.size': 'Dimensione',
|
||||||
|
'projects.files.empty': 'Questa cartella è vuota.',
|
||||||
|
'projects.files.refresh': 'Aggiorna',
|
||||||
|
'projects.files.drop': 'Trascina qui i file per caricarli',
|
||||||
|
'projects.files.btn.new_folder': 'Nuova cartella',
|
||||||
|
'projects.files.btn.upload': 'Carica',
|
||||||
|
'projects.files.uploading': 'Caricamento…',
|
||||||
|
'projects.files.action.rename': 'Rinomina',
|
||||||
|
'projects.files.action.delete': 'Elimina',
|
||||||
|
'projects.files.confirm.delete_file': 'Eliminare "{name}"?',
|
||||||
|
'projects.files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?',
|
||||||
|
'projects.files.modal.mkdir': 'Nuova cartella',
|
||||||
|
'projects.files.modal.rename': 'Rinomina "{name}"',
|
||||||
|
'projects.files.modal.name': 'Nome',
|
||||||
|
'projects.files.error.name': 'Inserisci un nome valido (senza barre).',
|
||||||
|
|
||||||
// ── Dettaglio progetto ──────────────────────────────────────────────────────
|
// ── Dettaglio progetto ──────────────────────────────────────────────────────
|
||||||
'project_board.back': 'Progetti',
|
'project_board.back': 'Progetti',
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ export class ChatSession extends LightElement {
|
|||||||
|
|
||||||
case 'approval_resolved': {
|
case 'approval_resolved': {
|
||||||
const { request_id, tool_call_id, approved } = msg;
|
const { request_id, tool_call_id, approved } = msg;
|
||||||
|
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||||||
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
|
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
|
||||||
if (tool_call_id != null) {
|
if (tool_call_id != null) {
|
||||||
if (approved) {
|
if (approved) {
|
||||||
@@ -419,6 +420,17 @@ export class ChatSession extends LightElement {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'approval_requested':
|
||||||
|
case 'clarification_requested':
|
||||||
|
case 'clarification_resolved':
|
||||||
|
case 'elicitation_requested':
|
||||||
|
case 'elicitation_resolved':
|
||||||
|
// Inbox lifecycle from any of this user's sessions (chat, cron,
|
||||||
|
// background): nudge listeners (sidebar badge, inbox page) to refresh
|
||||||
|
// immediately instead of waiting for the next poll.
|
||||||
|
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'agent_question':
|
case 'agent_question':
|
||||||
// Link the question form to the tool card by updating status + storing request_id.
|
// Link the question form to the tool card by updating status + storing request_id.
|
||||||
this._updateTool(msg.tool_call_id, {
|
this._updateTool(msg.tool_call_id, {
|
||||||
|
|||||||
Reference in New Issue
Block a user