From 94113ebd2fbd486e6b83182b5ca9a9b471ef2040 Mon Sep 17 00:00:00 2001 From: QAyong Date: Thu, 8 Oct 2026 12:58:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(buddy):=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E6=96=87=E4=BB=B6=E5=AE=89=E5=85=A8=E8=AF=BB?= =?UTF-8?q?=E6=94=B9=EF=BC=88=E7=A9=BA=E9=97=B4=E6=96=87=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=8E=9F=E7=94=9F=E6=96=87=E4=BB=B6=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E9=98=B2=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/buddy/electron/main/local-chat/spaces.ts | 4 +- .../electron/preload/local-chat/spaces.ts | 2 + apps/buddy/electron/shared/localChatApi.ts | 4 + .../host/__tests__/file_mutation_windows.rs | 328 ++++++++++++++++++ apps/buddy/native/host/src/bin/file_reader.rs | 10 + apps/buddy/native/host/src/file_mutation.rs | 89 +++++ .../native/host/src/file_mutation/windows.rs | 277 +++++++++++++++ .../host/src/file_mutation/windows/recycle.rs | 225 ++++++++++++ apps/buddy/native/host/src/lib.rs | 1 + .../__tests__/mutateBoundedEntry.spec.ts | 70 ++++ .../platform/filesystem/mutateBoundedEntry.ts | 72 ++++ .../service/src/spaces/SpaceFileService.ts | 58 +++- .../__tests__/SpaceFileMutation.spec.ts | 178 ++++++++++ .../spaces/__tests__/SpaceFileService.spec.ts | 17 +- .../src/spaces/registerSpaceFileRpc.ts | 1 + .../spaces/__tests__/spaceFileNames.spec.ts | 25 ++ apps/buddy/shared/spaces/spaceFileApi.ts | 20 +- apps/buddy/shared/spaces/spaceFileNames.ts | 15 + .../src/app/bootstrap/DesktopAppProvider.vue | 2 +- .../src/app/workbench/WorkbenchDiagnostics.ts | 4 +- .../workbench/WorkspaceFileMutationGuard.ts | 44 +++ .../app/workbench/WorkspaceFileOperations.ts | 130 +++++++ .../WorkspaceFileMutationGuard.spec.ts | 80 +++++ .../__tests__/WorkspaceFileOperations.spec.ts | 216 ++++++++++++ .../src/app/workbench/useDesktopWorkbench.ts | 101 +++++- apps/buddy/src/i18n/locales/en-US/tasks.ts | 41 +++ apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 41 +++ .../files/widgets/DesktopFileEditor.vue | 13 +- apps/buddy/src/modules/tasks/contracts.ts | 4 +- .../model/context-panel/workspaceFilesApi.ts | 17 + .../DesktopFilesContextSurface.vue | 21 +- .../DesktopTaskResourcePanel.vue | 2 +- .../DesktopWorkspaceFileMenu.vue | 314 +++++++++++++++++ .../DesktopWorkspaceFileMenu.spec.ts | 275 +++++++++++++++ .../WorkspaceFileDialogLayout.spec.ts | 51 +++ .../WorkspaceFileSynchronization.spec.ts | 92 +++++ .../context-panel/useWorkspaceFilePreview.ts | 94 +++-- .../ui/files/DesktopContextFileTree.vue | 26 +- .../shared/ui/files/fileTreeContextMenu.ts | 8 + .../workbench/services/WorkbenchController.ts | 22 +- .../workbench/services/WorkingCopyService.ts | 82 ++++- 41 files changed, 3016 insertions(+), 60 deletions(-) create mode 100644 apps/buddy/native/host/__tests__/file_mutation_windows.rs create mode 100644 apps/buddy/native/host/src/file_mutation.rs create mode 100644 apps/buddy/native/host/src/file_mutation/windows.rs create mode 100644 apps/buddy/native/host/src/file_mutation/windows/recycle.rs create mode 100644 apps/buddy/platform/filesystem/__tests__/mutateBoundedEntry.spec.ts create mode 100644 apps/buddy/platform/filesystem/mutateBoundedEntry.ts create mode 100644 apps/buddy/service/src/spaces/__tests__/SpaceFileMutation.spec.ts create mode 100644 apps/buddy/shared/spaces/__tests__/spaceFileNames.spec.ts create mode 100644 apps/buddy/shared/spaces/spaceFileNames.ts create mode 100644 apps/buddy/src/app/workbench/WorkspaceFileMutationGuard.ts create mode 100644 apps/buddy/src/app/workbench/WorkspaceFileOperations.ts create mode 100644 apps/buddy/src/app/workbench/__tests__/WorkspaceFileMutationGuard.spec.ts create mode 100644 apps/buddy/src/app/workbench/__tests__/WorkspaceFileOperations.spec.ts create mode 100644 apps/buddy/src/modules/tasks/model/context-panel/workspaceFilesApi.ts create mode 100644 apps/buddy/src/modules/tasks/widgets/context-panel/DesktopWorkspaceFileMenu.vue create mode 100644 apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/DesktopWorkspaceFileMenu.spec.ts create mode 100644 apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/WorkspaceFileDialogLayout.spec.ts create mode 100644 apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/WorkspaceFileSynchronization.spec.ts create mode 100644 apps/buddy/src/shared/ui/files/fileTreeContextMenu.ts diff --git a/apps/buddy/electron/main/local-chat/spaces.ts b/apps/buddy/electron/main/local-chat/spaces.ts index d2e2482f..1fbba468 100644 --- a/apps/buddy/electron/main/local-chat/spaces.ts +++ b/apps/buddy/electron/main/local-chat/spaces.ts @@ -2,7 +2,7 @@ import type { LocalChatIpcContext } from './registrar' import { shell } from 'electron' import { validationRequestSchemas } from '../../../shared/runtime/apiValidation' import { spacesRequestSchemas, spacesRpc } from '../../../shared/spaces/spaceApi' -import { spaceDirectoryRequestSchema, spaceFilesRpc, spaceFileTargetSchema, spaceSaveDocumentSchema } from '../../../shared/spaces/spaceFileApi' +import { spaceDirectoryRequestSchema, spaceFileMutationSchema, spaceFilesRpc, spaceFileTargetSchema, spaceSaveDocumentSchema } from '../../../shared/spaces/spaceFileApi' import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' import { translateDesktopNative } from '../desktopNativeI18n' import { SpaceDirectorySelectionLedger } from '../spaceDirectorySelections' @@ -11,6 +11,8 @@ import { selectPaths } from './nativeSelection' export function registerSpacesIpc(context: LocalChatIpcContext): void { const { handle, request, options } = context const spaceDirectorySelections = new SpaceDirectorySelectionLedger() + handle(LOCAL_CHAT_IPC_CHANNELS.spaceFilesMutate, (_event, input) => request(spaceFilesRpc.mutate, spaceFileMutationSchema.parse(input), 125_000)) + handle(LOCAL_CHAT_IPC_CHANNELS.spaceFilesLocate, (_event, input) => request(spaceFilesRpc.locate, spaceFileTargetSchema.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentRead, (_event, input) => request(spaceFilesRpc.readDocument, spaceFileTargetSchema.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentSave, (_event, input) => request(spaceFilesRpc.saveDocument, spaceSaveDocumentSchema.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.spaceFilesList, (_event, input) => request(spaceFilesRpc.list, spaceDirectoryRequestSchema.parse(input))) diff --git a/apps/buddy/electron/preload/local-chat/spaces.ts b/apps/buddy/electron/preload/local-chat/spaces.ts index 5353206e..612b33b2 100644 --- a/apps/buddy/electron/preload/local-chat/spaces.ts +++ b/apps/buddy/electron/preload/local-chat/spaces.ts @@ -10,6 +10,8 @@ export function createSpacesApi(): Pick { onChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.spacesChanged, listener), readDocument: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentRead, { ...input }), saveDocument: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceDocumentSave, { ...input }), + mutateEntry: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesMutate, { ...input }), + locateEntry: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesLocate, { ...input }), listDirectory: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesList, { ...input }), readFile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesRead, { ...input }), revealFile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.spaceFilesReveal, { ...input }), diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index ebd4c180..e7a8dc6b 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -52,6 +52,8 @@ export const LOCAL_CHAT_IPC_CHANNELS = { taskMarksSetRead: 'lexora:buddy:task-marks:set-read', taskMarksClear: 'lexora:buddy:task-marks:clear', changesOverview: 'lexora:buddy:changes:overview', + spaceFilesMutate: 'lexora:buddy:space-files:mutate', + spaceFilesLocate: 'lexora:buddy:space-files:locate', spaceFilesList: 'lexora:buddy:space-files:list', spaceFilesRead: 'lexora:buddy:space-files:read', spaceFilesReveal: 'lexora:buddy:space-files:reveal', @@ -320,6 +322,8 @@ export interface LocalChatApi { onChanged: (listener: (event: SpaceChangeNotice) => void) => () => void readDocument: (input: SpaceFileTarget) => Promise saveDocument: (input: import('../../shared/spaces/spaceFileApi').SpaceSaveDocument) => Promise + mutateEntry: (input: import('../../shared/spaces/spaceFileApi').SpaceFileMutation) => Promise + locateEntry: (input: SpaceFileTarget) => Promise<{ path: string, kind: 'directory' | 'file' }> listDirectory: (input: SpaceDirectoryRequest) => Promise readFile: (input: SpaceFileTarget) => Promise revealFile: (input: SpaceFileTarget) => Promise diff --git a/apps/buddy/native/host/__tests__/file_mutation_windows.rs b/apps/buddy/native/host/__tests__/file_mutation_windows.rs new file mode 100644 index 00000000..6ad64204 --- /dev/null +++ b/apps/buddy/native/host/__tests__/file_mutation_windows.rs @@ -0,0 +1,328 @@ +use super::*; + +fn root(directory: &Path) -> PathBuf { + let path = fs::canonicalize(directory).unwrap(); + let path = path.to_str().unwrap(); + PathBuf::from(path.strip_prefix(r"\\?\").unwrap()) +} +fn request(root: &Path, path: &Path, operation: Operation, name: Option<&str>) -> MutationRequest { + MutationRequest { + root: root.to_str().unwrap().into(), + path: path.to_str().unwrap().into(), + operation, + name: name.map(str::to_owned), + } +} + +#[test] +fn creates_empty_entries_and_never_overwrites() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + prepare(request( + &root, + &root, + Operation::CreateFile, + Some("note.md"), + )) + .unwrap() + .commit() + .unwrap(); + fs::write(root.join("note.md"), "keep").unwrap(); + assert!(matches!( + prepare(request( + &root, + &root, + Operation::CreateFile, + Some("note.md") + )) + .unwrap() + .commit(), + Err(MutationError::Exists) + )); + assert_eq!(fs::read_to_string(root.join("note.md")).unwrap(), "keep"); + prepare(request( + &root, + &root, + Operation::CreateDirectory, + Some("folder"), + )) + .unwrap() + .commit() + .unwrap(); + assert!(root.join("folder").is_dir()); + assert!( + prepare(request( + &root, + &root, + Operation::CreateDirectory, + Some("folder") + )) + .unwrap() + .commit() + .is_err() + ); +} + +#[test] +fn creation_pins_empty_directory_ancestors_without_a_source_file() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + let parent = root.join("empty"); + fs::create_dir(&parent).unwrap(); + let prepared = prepare(request( + &root, + &parent, + Operation::CreateFile, + Some("new.md"), + )) + .unwrap(); + assert!(fs::rename(&parent, root.join("moved")).is_err()); + assert!(fs::remove_dir(&parent).is_err()); + prepared.commit().unwrap(); + assert_eq!(fs::read(parent.join("new.md")).unwrap(), b""); +} + +#[test] +fn source_and_ancestors_cannot_be_replaced_while_prepared() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + fs::create_dir(root.join("folder")).unwrap(); + fs::write(root.join("folder/old.md"), "original").unwrap(); + let prepared = prepare(request( + &root, + &root.join("folder").join("old.md"), + Operation::Rename, + Some("new.md"), + )) + .unwrap(); + assert!(fs::rename(root.join("folder"), root.join("moved")).is_err()); + assert!(fs::rename(root.join("folder/old.md"), root.join("folder/replaced.md")).is_err()); + // A destination appearing after preparation still cannot be overwritten. + fs::write(root.join("folder/new.md"), "destination").unwrap(); + assert!(matches!(prepared.commit(), Err(MutationError::Exists))); + assert_eq!( + fs::read_to_string(root.join("folder/old.md")).unwrap(), + "original" + ); + assert_eq!( + fs::read_to_string(root.join("folder/new.md")).unwrap(), + "destination" + ); +} + +#[test] +fn renames_files_and_directories_by_identity_without_overwriting() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + fs::write(root.join("old.md"), "original").unwrap(); + prepare(request( + &root, + &root.join("old.md"), + Operation::Rename, + Some("new.md"), + )) + .unwrap() + .commit() + .unwrap(); + assert_eq!(fs::read_to_string(root.join("new.md")).unwrap(), "original"); + assert!(!root.join("old.md").exists()); + fs::create_dir(root.join("folder")).unwrap(); + fs::write(root.join("folder/child"), "child").unwrap(); + prepare(request( + &root, + &root.join("folder"), + Operation::Rename, + Some("renamed"), + )) + .unwrap() + .commit() + .unwrap(); + assert_eq!( + fs::read_to_string(root.join("renamed/child")).unwrap(), + "child" + ); +} + +#[test] +fn rename_preserves_exact_unicode_names_at_every_buffer_alignment() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + for padding in 0..8 { + let source = root.join(format!("source-{padding}.txt")); + fs::write(&source, "original").unwrap(); + let name = format!("extracted1.txt操作📝{}", "x".repeat(padding)); + prepare(request(&root, &source, Operation::Rename, Some(&name))) + .unwrap() + .commit() + .unwrap(); + assert!( + root.join(&name).exists(), + "expected exact name: {name:?}; actual: {:?}", + fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>() + ); + assert_eq!(fs::read_to_string(root.join(&name)).unwrap(), "original"); + } +} + +#[test] +fn invalid_names_case_only_root_mutation_and_escapes_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + fs::write(root.join("note.md"), "keep").unwrap(); + for name in [ + "", ".", "..", "a/b", "a\\b", "NUL", "CON.txt", "name.", "name ", "a:b", + ] { + assert!( + prepare(request(&root, &root, Operation::CreateFile, Some(name))).is_err(), + "{name}" + ); + } + assert!(matches!( + prepare(request( + &root, + &root.join("note.md"), + Operation::Rename, + Some("NOTE.md") + )), + Err(MutationError::CaseOnly) + )); + for operation in [Operation::Rename, Operation::Trash] { + assert!( + prepare(request( + &root, + &root, + operation, + if operation == Operation::Rename { + Some("new") + } else { + None + } + )) + .is_err() + ); + } + assert!( + prepare(request( + &root, + &root.parent().unwrap().join("outside"), + Operation::CreateFile, + Some("wrong") + )) + .is_err() + ); + assert_eq!(fs::read_to_string(root.join("note.md")).unwrap(), "keep"); +} + +fn recycle_fixture(root: &Path, name: &str) -> (PreparedMutation, PathBuf, PathBuf) { + let bin = root.join("fixture-bin"); + fs::create_dir(&bin).unwrap(); + // Use the real source/ancestor preparation; only the system bin destination is replaced + // with a private test directory. Production never accepts a renderer-supplied bin path. + let mut prepared = prepare(request( + root, + &root.join(name), + Operation::Rename, + Some("unused"), + )) + .unwrap(); + prepared.request.operation = Operation::Trash; + prepared.destination = None; + let destination = recycle::Destination::fixture(&bin).unwrap(); + let (record, payload) = destination.paths(); + let paths = (record.to_owned(), payload.to_owned()); + prepared.recycle = Some(destination); + (prepared, paths.0, paths.1) +} + +#[test] +fn recycle_keeps_source_and_bin_pinned_and_moves_original_by_handle() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + let name = "original-中文😀.md"; + fs::write(root.join(name), "original data").unwrap(); + let (prepared, record, payload) = recycle_fixture(&root, name); + assert!(fs::rename(root.join(name), root.join("substitute.md")).is_err()); + assert!(fs::remove_file(root.join(name)).is_err()); + assert!(fs::rename(root.join("fixture-bin"), root.join("moved-bin")).is_err()); + prepared.commit().unwrap(); + assert!(!root.join(name).exists()); + assert_eq!(fs::read_to_string(payload).unwrap(), "original data"); + let metadata = fs::read(record).unwrap(); + assert_eq!(&metadata[..8], &2u64.to_le_bytes()); + assert_eq!(&metadata[8..16], &13u64.to_le_bytes()); + let original = root + .join(name) + .to_str() + .unwrap() + .encode_utf16() + .chain(Some(0)) + .collect::>(); + assert_eq!(&metadata[24..28], &(original.len() as u32).to_le_bytes()); + assert_eq!( + &metadata[28..], + &original + .iter() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>() + ); +} + +#[test] +fn recycle_payload_collision_preserves_both_files_and_cleans_only_own_record() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + fs::write(root.join("original.md"), "original").unwrap(); + let (prepared, record, payload) = recycle_fixture(&root, "original.md"); + fs::write(&payload, "existing payload").unwrap(); + assert!(matches!(prepared.commit(), Err(MutationError::Exists))); + assert_eq!( + fs::read_to_string(root.join("original.md")).unwrap(), + "original" + ); + assert_eq!(fs::read_to_string(payload).unwrap(), "existing payload"); + assert!(!record.exists()); +} + +#[test] +fn recycle_record_collision_never_overwrites_or_cleans_a_foreign_record() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + fs::write(root.join("original.md"), "original").unwrap(); + let (prepared, record, payload) = recycle_fixture(&root, "original.md"); + fs::write(&record, "existing metadata").unwrap(); + assert!(matches!(prepared.commit(), Err(MutationError::Exists))); + assert_eq!( + fs::read_to_string(root.join("original.md")).unwrap(), + "original" + ); + assert_eq!(fs::read_to_string(record).unwrap(), "existing metadata"); + assert!(!payload.exists()); +} + +#[test] +fn dropping_preparation_and_canceling_protocol_do_not_write() { + let temp = tempfile::tempdir().unwrap(); + let root = root(temp.path()); + drop( + prepare(request( + &root, + &root, + Operation::CreateFile, + Some("not-created"), + )) + .unwrap(), + ); + assert!(!root.join("not-created").exists()); + let input = format!( + "{{\"root\":{},\"path\":{},\"operation\":\"create-file\",\"name\":\"not-created\"}}\ncancel\n", + serde_json::to_string(root.to_str().unwrap()).unwrap(), + serde_json::to_string(root.to_str().unwrap()).unwrap() + ); + let mut output = Vec::new(); + assert!(crate::file_mutation::run(std::io::Cursor::new(input), &mut output).is_err()); + assert_eq!(output, b"ready\n"); + assert!(!root.join("not-created").exists()); +} diff --git a/apps/buddy/native/host/src/bin/file_reader.rs b/apps/buddy/native/host/src/bin/file_reader.rs index 5d92bf3b..8772887d 100644 --- a/apps/buddy/native/host/src/bin/file_reader.rs +++ b/apps/buddy/native/host/src/bin/file_reader.rs @@ -1,6 +1,16 @@ use std::{io, process::ExitCode}; fn main() -> ExitCode { + if std::env::args().nth(1).as_deref() == Some("--mutate-entry") { + return match lexora_buddy_host::file_mutation::run(io::stdin().lock(), io::stdout().lock()) + { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + }; + } if std::env::args().nth(1).as_deref() == Some("--save-text") { return match lexora_buddy_host::file_writer::run(io::stdin().lock()) { Ok(()) => ExitCode::SUCCESS, diff --git a/apps/buddy/native/host/src/file_mutation.rs b/apps/buddy/native/host/src/file_mutation.rs new file mode 100644 index 00000000..ce51d895 --- /dev/null +++ b/apps/buddy/native/host/src/file_mutation.rs @@ -0,0 +1,89 @@ +//! Small, fail-closed Windows entry mutations. No shell commands or permanent-delete fallback. +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, Read, Write}; + +#[cfg(windows)] +mod windows; + +#[derive(Debug, thiserror::Error)] +pub enum MutationError { + #[error("invalid-name")] + InvalidName, + #[error("exists")] + Exists, + #[error("missing")] + Missing, + #[error("unsafe-path")] + UnsafePath, + #[error("permission")] + Permission, + #[error("busy")] + Busy, + #[error("unsupported")] + Unsupported, + #[error("case-only")] + CaseOnly, + #[error("failed")] + Failed, +} + +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Operation { + CreateFile, + CreateDirectory, + Rename, + Trash, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MutationRequest { + pub root: String, + pub path: String, + pub operation: Operation, + pub name: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EntryKind { + File, + Directory, +} + +#[cfg_attr(not(windows), allow(unused_mut))] +pub fn run(mut input: impl BufRead, mut output: impl Write) -> Result<(), MutationError> { + let mut request = String::new(); + Read::take(input.by_ref(), 65537) + .read_line(&mut request) + .map_err(|_| MutationError::Failed)?; + if request.len() > 65536 { + return Err(MutationError::Failed); + } + let request: MutationRequest = + serde_json::from_str(&request).map_err(|_| MutationError::Failed)?; + #[cfg(windows)] + { + let prepared = windows::prepare(request)?; + // Ancestors and source stay pinned while the service rechecks the live grant. + writeln!(output, "ready").map_err(|_| MutationError::Failed)?; + output.flush().map_err(|_| MutationError::Failed)?; + let mut decision = String::new(); + Read::take(input.by_ref(), 16) + .read_line(&mut decision) + .map_err(|_| MutationError::Failed)?; + if decision != "commit\n" { + return Err(MutationError::Failed); + } + let kind = prepared.commit()?; + serde_json::to_writer(&mut output, &kind).map_err(|_| MutationError::Failed)?; + output.flush().map_err(|_| MutationError::Failed)?; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (request, output); + Err(MutationError::Unsupported) + } +} diff --git a/apps/buddy/native/host/src/file_mutation/windows.rs b/apps/buddy/native/host/src/file_mutation/windows.rs new file mode 100644 index 00000000..d451d27a --- /dev/null +++ b/apps/buddy/native/host/src/file_mutation/windows.rs @@ -0,0 +1,277 @@ +use super::{EntryKind, MutationError, MutationRequest, Operation}; +use std::{ + fs::{self, File, OpenOptions}, + mem::{offset_of, size_of, size_of_val}, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + path::{Path, PathBuf}, + ptr, +}; +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, + FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_SHARE_READ, FILE_SHARE_WRITE, + FileAttributeTagInfo, FileRenameInfo, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, + SetFileInformationByHandle, +}; + +mod recycle; + +pub(super) struct PreparedMutation { + request: MutationRequest, + destination: Option, + source: Option, + kind: EntryKind, + recycle: Option, + _ancestors: Vec, +} + +pub(super) fn prepare(request: MutationRequest) -> Result { + if !crate::windows_path::valid(&request.root) || !crate::windows_path::valid(&request.path) { + return Err(MutationError::UnsafePath); + } + let root = Path::new(&request.root); + let path = Path::new(&request.path); + if !path.starts_with(root) { + return Err(MutationError::UnsafePath); + } + let create = matches!( + request.operation, + Operation::CreateFile | Operation::CreateDirectory + ); + if !create && path == root { + return Err(MutationError::UnsafePath); + } + let parent = if create { + path + } else { + path.parent().ok_or(MutationError::UnsafePath)? + }; + let mut ancestors = Vec::new(); + // Deny ancestor renames/deletion and reject *all* reparse points, including above the grant. + for directory in parent.ancestors().collect::>().into_iter().rev() { + ancestors.push(pin_directory(directory)?); + } + let destination = if request.operation != Operation::Trash { + let name = request.name.as_deref().ok_or(MutationError::InvalidName)?; + if name.is_empty() + || name == "." + || name == ".." + || name.contains(['/', '\\']) + || name.encode_utf16().count() > 255 + || name.chars().any(|c| c < ' ' || c == '\u{7f}') + { + return Err(MutationError::InvalidName); + } + let destination = parent.join(name); + if !crate::windows_path::valid(destination.to_str().ok_or(MutationError::InvalidName)?) { + return Err(MutationError::InvalidName); + } + if request.operation == Operation::Rename { + let old_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(MutationError::UnsafePath)?; + if old_name != name && old_name.to_lowercase() == name.to_lowercase() { + return Err(MutationError::CaseOnly); + } + } + Some(destination) + } else { + if request.name.is_some() { + return Err(MutationError::InvalidName); + } + None + }; + let source = if create { + None + } else { + let handle = OpenOptions::new() + .access_mode(DELETE | FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(io_error)?; + if final_path(&handle)? != request.path.trim_end_matches('\\') { + return Err(MutationError::UnsafePath); + } + inspect(&handle)?; + Some(handle) + }; + let kind = match request.operation { + Operation::CreateFile => EntryKind::File, + Operation::CreateDirectory => EntryKind::Directory, + _ => inspect(source.as_ref().ok_or(MutationError::Failed)?)?, + }; + let recycle = if request.operation == Operation::Trash { + Some(recycle::prepare(path)?) + } else { + None + }; + Ok(PreparedMutation { + request, + destination, + source, + kind, + recycle, + _ancestors: ancestors, + }) +} + +impl PreparedMutation { + pub(super) fn commit(self) -> Result { + match self.request.operation { + Operation::CreateFile => { + OpenOptions::new() + .write(true) + .create_new(true) + .open(self.destination.as_ref().ok_or(MutationError::Failed)?) + .map_err(io_error)?; + } + Operation::CreateDirectory => { + fs::create_dir(self.destination.as_ref().ok_or(MutationError::Failed)?) + .map_err(io_error)?; + } + Operation::Rename => { + let destination = self.destination.as_ref().ok_or(MutationError::Failed)?; + if destination != Path::new(&self.request.path) { + rename_by_handle( + self.source.as_ref().ok_or(MutationError::Failed)?, + destination, + )?; + } + } + Operation::Trash => { + self.recycle + .as_ref() + .ok_or(MutationError::Unsupported)? + .commit( + &self.request.path, + self.source.as_ref().ok_or(MutationError::Failed)?, + )?; + } + } + Ok(self.kind) + } +} + +fn pin_directory(directory: &Path) -> Result { + let handle = OpenOptions::new() + // Metadata-only opens do not pin the directory namespace on NTFS. + // Match the existing bounded writer: LIST_DIRECTORY makes delete sharing effective. + .access_mode( + FILE_LIST_DIRECTORY + | FILE_READ_ATTRIBUTES + | windows_sys::Win32::Storage::FileSystem::READ_CONTROL, + ) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(directory) + .map_err(io_error)?; + if !matches!(inspect(&handle)?, EntryKind::Directory) + || final_path(&handle)? != directory.to_string_lossy().trim_end_matches('\\') + { + return Err(MutationError::UnsafePath); + } + Ok(handle) +} + +fn inspect(file: &File) -> Result { + let mut attributes = FILE_ATTRIBUTE_TAG_INFO::default(); + // SAFETY: The owned handle and correctly sized output buffer remain live throughout the call. + if unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileAttributeTagInfo, + ptr::from_mut(&mut attributes).cast(), + size_of_val(&attributes) as u32, + ) + } == 0 + { + return Err(io_error(std::io::Error::last_os_error())); + } + if attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(MutationError::UnsafePath); + } + Ok( + if attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + EntryKind::Directory + } else { + EntryKind::File + }, + ) +} + +fn final_path(file: &File) -> Result { + let mut buffer = vec![0u16; 32768]; + // SAFETY: File owns the handle and the output buffer covers the advertised capacity. + let size = unsafe { + GetFinalPathNameByHandleW( + file.as_raw_handle(), + buffer.as_mut_ptr(), + buffer.len() as u32, + 0, + ) + } as usize; + if size == 0 || size >= buffer.len() { + return Err(MutationError::UnsafePath); + } + let path = String::from_utf16(&buffer[..size]).map_err(|_| MutationError::UnsafePath)?; + let path = if let Some(path) = path.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{path}") + } else { + path.strip_prefix("\\\\?\\") + .ok_or(MutationError::UnsafePath)? + .to_owned() + }; + Ok(path.trim_end_matches('\\').to_owned()) +} + +fn rename_by_handle(source: &File, destination: &Path) -> Result<(), MutationError> { + let name: Vec = destination + .to_str() + .ok_or(MutationError::UnsafePath)? + .encode_utf16() + .collect(); + let offset = offset_of!(FILE_RENAME_INFO, FileName); + // SetFileInformationByHandle also expects FileName to be NUL-terminated. + // FileNameLength excludes that terminator; alignment padding is not a substitute: + // an exactly filled allocation otherwise lets the Win32 path conversion read past it. + let length = (offset + (name.len() + 1) * size_of::()).max(size_of::()); + // u64 storage gives the flexible structure its required pointer alignment on both architectures. + let mut storage = vec![0u64; length.div_ceil(8)]; + let info = storage.as_mut_ptr().cast::(); + // SAFETY: Aligned storage covers the header, UTF-16 filename and trailing NUL. It starts zeroed: + // ReplaceIfExists is false, so the OS atomically refuses an existing destination. + unsafe { + (*info).FileNameLength = (name.len() * 2) as u32; + ptr::copy_nonoverlapping( + name.as_ptr(), + storage.as_mut_ptr().cast::().add(offset).cast::(), + name.len(), + ); + if SetFileInformationByHandle( + source.as_raw_handle(), + FileRenameInfo, + info.cast(), + length as u32, + ) == 0 + { + return Err(io_error(std::io::Error::last_os_error())); + } + } + Ok(()) +} + +fn io_error(error: std::io::Error) -> MutationError { + match error.raw_os_error() { + Some(2 | 3) => MutationError::Missing, + Some(5) => MutationError::Permission, + Some(32 | 33) => MutationError::Busy, + Some(80 | 183) => MutationError::Exists, + _ => MutationError::Failed, + } +} + +#[cfg(test)] +#[path = "../../__tests__/file_mutation_windows.rs"] +mod tests; diff --git a/apps/buddy/native/host/src/file_mutation/windows/recycle.rs b/apps/buddy/native/host/src/file_mutation/windows/recycle.rs new file mode 100644 index 00000000..3d798e2e --- /dev/null +++ b/apps/buddy/native/host/src/file_mutation/windows/recycle.rs @@ -0,0 +1,225 @@ +//! Windows 10/11, local NTFS, initialized per-user Recycle Bin only. +//! Move the pinned source by handle; never hand an unlocked source path to Shell deletion. +//! $I v2 layout reference (format facts, not copied implementation): +//! https://github.com/libyal/dtformats/blob/main/documentation/Windows%20Recycle.Bin%20file%20formats.asciidoc +use super::{MutationError, io_error, pin_directory, rename_by_handle}; +use crate::windows_security::{Sid, process_user_sid}; +use std::{ + ffi::c_void, + fs::{File, OpenOptions}, + io::Write, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + path::{Path, PathBuf}, + ptr, + time::{SystemTime, UNIX_EPOCH}, +}; +use windows_sys::{ + Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::{ConvertSidToStringSidW, GetSecurityInfo, SE_FILE_OBJECT}, + OWNER_SECURITY_INFORMATION, + }, + Storage::FileSystem::{ + DELETE, FILE_DISPOSITION_INFO, FILE_SHARE_READ, FILE_WRITE_DATA, FileDispositionInfo, + GetDriveTypeW, GetVolumeInformationW, SetFileInformationByHandle, + }, + System::{Com::CoCreateGuid, Threading::GetCurrentProcess}, + }, + core::GUID, +}; + +pub(super) struct Destination { + record: PathBuf, + payload: PathBuf, + _directories: Vec, +} + +pub(super) fn prepare(source: &Path) -> Result { + let text = source.to_str().ok_or(MutationError::UnsafePath)?; + if text.len() < 3 || text.as_bytes()[1..3] != *b":\\" { + return Err(MutationError::Unsupported); + } + let volume = &text[..3]; + let wide: Vec = volume.encode_utf16().chain(Some(0)).collect(); + let mut filesystem = [0u16; 32]; + // SAFETY: NUL-terminated volume. 3 is DRIVE_FIXED. + if unsafe { GetDriveTypeW(wide.as_ptr()) } != 3 { + return Err(MutationError::Unsupported); + } + // SAFETY: NUL-terminated volume and live, correctly sized filesystem output storage. + if unsafe { + GetVolumeInformationW( + wide.as_ptr(), + ptr::null_mut(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + filesystem.as_mut_ptr(), + filesystem.len() as u32, + ) + } == 0 + || filesystem[..5] != [78, 84, 70, 83, 0] + { + return Err(MutationError::Unsupported); + } + let bin = Path::new(volume).join("$Recycle.Bin"); + if text + .to_lowercase() + .starts_with(&format!("{}\\", bin.display()).to_lowercase()) + { + return Err(MutationError::UnsafePath); + } + // SAFETY: Borrowed pseudo process handle; SID storage owns its bytes. + let user = process_user_sid(unsafe { GetCurrentProcess() }).map_err(io_error)?; + let mut sid = ptr::null_mut(); + // SAFETY: Valid owned SID, initialized output, allocation is owned below. + if unsafe { ConvertSidToStringSidW(user.as_ptr(), &mut sid) } == 0 { + return Err(io_error(std::io::Error::last_os_error())); + } + let sid = LocalMemory(sid.cast()); + let mut units = Vec::new(); + for index in 0..256 { + // SAFETY: ConvertSidToStringSidW guarantees a NUL-terminated SID string. + let unit = unsafe { *sid.0.cast::().add(index) }; + if unit == 0 { + break; + } + units.push(unit); + } + let sid = String::from_utf16(&units).map_err(|_| MutationError::Unsupported)?; + let directory = bin.join(sid); + // Never create/configure a system bin or change its ACL. Unsupported/missing bins fail closed. + let bin_handle = pin_directory(&bin).map_err(|_| MutationError::Unsupported)?; + let directory_handle = pin_directory(&directory).map_err(|_| MutationError::Unsupported)?; + let mut owner = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + // SAFETY: Pinned directory includes READ_CONTROL; owner belongs to returned descriptor storage. + let status = unsafe { + GetSecurityInfo( + directory_handle.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut descriptor, + ) + }; + let _descriptor = LocalMemory(descriptor.cast()); + if status != 0 { + return Err(MutationError::Unsupported); + } + // SAFETY: Successful GetSecurityInfo initialized owner in the live descriptor. + if unsafe { Sid::copy(owner) }.map_err(io_error)? != user { + return Err(MutationError::UnsafePath); + } + Destination::new(&directory, vec![bin_handle, directory_handle]) +} + +impl Destination { + fn new(directory: &Path, handles: Vec) -> Result { + let mut guid = GUID::default(); + // SAFETY: Initialized, writable GUID output. No COM apartment is required for this API. + if unsafe { CoCreateGuid(&mut guid) } < 0 { + return Err(MutationError::Unsupported); + } + let id = format!( + "{:08x}{:04x}{:04x}{:016x}", + guid.data1, + guid.data2, + guid.data3, + u64::from_be_bytes(guid.data4) + ); + Ok(Self { + record: directory.join(format!("$I{id}")), + payload: directory.join(format!("$R{id}")), + _directories: handles, + }) + } + + pub(super) fn commit(&self, original: &str, source: &File) -> Result<(), MutationError> { + let mut record = Record { + file: OpenOptions::new() + .write(true) + .access_mode(FILE_WRITE_DATA | DELETE) + .share_mode(FILE_SHARE_READ) + .create_new(true) + .open(&self.record) + .map_err(io_error)?, + keep: false, + }; + let path: Vec = original.encode_utf16().chain(Some(0)).collect(); + let ticks = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| MutationError::Failed)? + .as_nanos() + / 100, + ) + .ok() + .and_then(|ticks| ticks.checked_add(116_444_736_000_000_000)) + .ok_or(MutationError::Failed)?; + let mut metadata = Vec::with_capacity(28 + path.len() * 2); + metadata.extend_from_slice(&2u64.to_le_bytes()); + metadata.extend_from_slice(&source.metadata().map_err(io_error)?.len().to_le_bytes()); + metadata.extend_from_slice(&ticks.to_le_bytes()); + metadata.extend_from_slice(&(path.len() as u32).to_le_bytes()); + for unit in path { + metadata.extend_from_slice(&unit.to_le_bytes()); + } + record.file.write_all(&metadata).map_err(io_error)?; + record.file.sync_all().map_err(io_error)?; + // Metadata is durable before moving data. The original handle NEVER leaves this caller. + // Pinned bin ancestors and OS no-replace rename cover both source identity and destination. + // On a kill after the move, the complete $I/$R pair remains recoverable by Windows Shell. + rename_by_handle(source, &self.payload)?; + record.keep = true; + Ok(()) + } + + #[cfg(test)] + pub(super) fn fixture(directory: &Path) -> Result { + Self::new(directory, vec![pin_directory(directory)?]) + } + #[cfg(test)] + pub(super) fn paths(&self) -> (&Path, &Path) { + (&self.record, &self.payload) + } +} + +// Cleanup is limited to the exclusively created metadata record, by its own handle. +// This can never delete the source/payload or a path substituted by another process. +struct Record { + file: File, + keep: bool, +} +impl Drop for Record { + fn drop(&mut self) { + if !self.keep { + let info = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: Owned DELETE-enabled metadata handle and correctly sized live structure. + unsafe { + SetFileInformationByHandle( + self.file.as_raw_handle(), + FileDispositionInfo, + ptr::from_ref(&info).cast(), + std::mem::size_of_val(&info) as u32, + ); + } + } + } +} +struct LocalMemory(*mut c_void); +impl Drop for LocalMemory { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: Only allocations returned by LocalAlloc-backed Win32 APIs are stored here. + unsafe { + LocalFree(self.0); + } + } + } +} diff --git a/apps/buddy/native/host/src/lib.rs b/apps/buddy/native/host/src/lib.rs index 2b33eac7..63949efa 100644 --- a/apps/buddy/native/host/src/lib.rs +++ b/apps/buddy/native/host/src/lib.rs @@ -1,3 +1,4 @@ +pub mod file_mutation; pub mod file_reader; pub mod file_writer; pub mod private_directories; diff --git a/apps/buddy/platform/filesystem/__tests__/mutateBoundedEntry.spec.ts b/apps/buddy/platform/filesystem/__tests__/mutateBoundedEntry.spec.ts new file mode 100644 index 00000000..1f46fe0a --- /dev/null +++ b/apps/buddy/platform/filesystem/__tests__/mutateBoundedEntry.spec.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mutateBoundedEntry } from '../mutateBoundedEntry' + +const mocks = vi.hoisted(() => ({ + execFile: vi.fn(), + process: { platform: 'win32', env: {} as Record }, +})) +vi.mock('node:child_process', () => ({ execFile: mocks.execFile })) +vi.mock('node:process', () => ({ default: mocks.process })) + +beforeEach(() => { + mocks.execFile.mockReset() + mocks.process.env = { + LEXORA_BUDDY_FILE_READER: '/trusted/helper', + SystemRoot: 'C:/Windows', + USERPROFILE: 'C:/Users/test', + LOCALAPPDATA: 'C:/Users/test/local', + HOME: '/home/test', + XDG_DATA_HOME: '/home/test/.local/share', + SECRET: 'not-forwarded', + } +}) + +const input = { root: '/workspace', path: '/workspace', operation: 'create-file' as const, name: 'note.md' } + +describe('cross-platform bounded mutation transport', () => { + it.each(['win32', 'darwin', 'linux'])('dispatches %s through the native authorization handshake', async (platform) => { + mocks.process.platform = platform + const authorize = vi.fn() + mocks.execFile.mockImplementation((_file, _args, _options, callback) => { + const stdout = new EventEmitter() + const stdin = Object.assign(new EventEmitter(), { + write: vi.fn(() => stdout.emit('data', 'ready\n')), + end: vi.fn((decision: string) => { + expect(decision).toBe('commit\n') + expect(authorize).toHaveBeenCalledOnce() + callback(null, 'ready\n"file"', '') + }), + }) + return { stdout, stdin } + }) + expect(await mutateBoundedEntry(input, authorize)).toEqual({ status: 'completed', kind: 'file' }) + const [executable, args, options] = mocks.execFile.mock.calls[0]! + expect(executable).toBe('/trusted/helper') + expect(args).toEqual(['--mutate-entry']) + expect(options.env).toEqual(platform === 'win32' + ? { SystemRoot: 'C:/Windows', USERPROFILE: 'C:/Users/test', LOCALAPPDATA: 'C:/Users/test/local' } + : platform === 'darwin' ? { HOME: '/home/test' } : { HOME: '/home/test', XDG_DATA_HOME: '/home/test/.local/share' }) + expect(options.env).not.toHaveProperty('SECRET') + }) + + it('does not claim support when the native backend rejects an operation', async () => { + mocks.process.platform = 'linux' + const authorize = vi.fn() + mocks.execFile.mockImplementation((_file, _args, _options, callback) => { + queueMicrotask(() => callback(new Error('unsupported'), '', 'unsupported')) + return { stdin: { on: vi.fn(), write: vi.fn() } } + }) + expect(await mutateBoundedEntry(input, authorize)).toEqual({ status: 'failed', reason: 'unsupported' }) + expect(authorize).not.toHaveBeenCalled() + expect(mocks.execFile).toHaveBeenCalledOnce() + }) + + it('rejects unknown platforms without spawning a helper', async () => { + mocks.process.platform = 'freebsd' + expect(await mutateBoundedEntry(input, vi.fn())).toEqual({ status: 'failed', reason: 'unsupported' }) + expect(mocks.execFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/buddy/platform/filesystem/mutateBoundedEntry.ts b/apps/buddy/platform/filesystem/mutateBoundedEntry.ts new file mode 100644 index 00000000..b49ceb30 --- /dev/null +++ b/apps/buddy/platform/filesystem/mutateBoundedEntry.ts @@ -0,0 +1,72 @@ +import type { SpaceFileMutationError } from '../../shared/spaces/spaceFileApi' +import { execFile } from 'node:child_process' +import process from 'node:process' +import { spaceFileMutationErrorSchema } from '../../shared/spaces/spaceFileApi' + +export interface BoundedEntryMutation { + root: string + path: string + operation: 'create-file' | 'create-directory' | 'rename' | 'trash' + name?: string +} +export type BoundedEntryMutationResult = { status: 'completed', kind: 'file' | 'directory' } | { status: 'failed', reason: SpaceFileMutationError } + +// Shared Windows/macOS/Linux transport; the native backend decides whether an operation +// can safely run on the target filesystem. Never fall back to unbounded Node/Shell writes. +// The callback is deliberately trusted-side only, never a renderer-supplied authorization. +export function mutateBoundedEntry(input: BoundedEntryMutation, beforeCommit: () => void): Promise { + const executable = process.env.LEXORA_BUDDY_FILE_READER + if (!['win32', 'darwin', 'linux'].includes(process.platform) || !executable) + return Promise.resolve({ status: 'failed', reason: 'unsupported' }) + return new Promise((resolve, reject) => { + let ready = false + let committed = false + let authorizationError: unknown + let prefix = '' + const child = execFile(executable, ['--mutate-entry'], { + // Forward only platform context, not the complete service environment. These + // values locate user facilities; native code must still verify ownership/bounds. + env: process.platform === 'win32' + ? { SystemRoot: process.env.SystemRoot, USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA } + : { HOME: process.env.HOME, ...(process.platform === 'linux' ? { XDG_DATA_HOME: process.env.XDG_DATA_HOME } : {}) }, + maxBuffer: 4096, + timeout: 120_000, + windowsHide: true, + }, (error, stdout, stderr) => { + if (authorizationError) { + reject(authorizationError) + return + } + if (error || stderr) { + const parsed = spaceFileMutationErrorSchema.safeParse(stderr.trim()) + const reason = parsed.success ? parsed.data : committed ? 'result-unknown' : 'unsupported' + resolve({ status: 'failed', reason: input.operation === 'trash' && committed ? 'result-unknown' : reason }) + return + } + const kind = stdout.replace(/^ready\r?\n/u, '').trim() + if (ready && committed && (kind === '"file"' || kind === '"directory"')) + resolve({ status: 'completed', kind: kind === '"file"' ? 'file' : 'directory' }) + else + resolve({ status: 'failed', reason: committed ? 'result-unknown' : 'failed' }) + }) + child.stdout?.on('data', (chunk: string | Uint8Array) => { + if (ready) + return + prefix += chunk.toString() + if (prefix === 'ready\n' || prefix === 'ready\r\n') { + ready = true + try { + beforeCommit() + committed = true + child.stdin?.end('commit\n') + } + catch (error) { + authorizationError = error + child.stdin?.end('cancel\n') + } + } + }) + child.stdin?.on('error', () => {}) + child.stdin?.write(`${JSON.stringify(input)}\n`) + }) +} diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index 9d3a7d88..ed494b94 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -1,11 +1,15 @@ -import type { LocalSpaceDirectoryPage, LocalSpaceFileEntry, LocalSpaceFilePreview, SpaceDirectoryRequest, SpaceFileTarget, SpaceSaveDocument, SpaceSaveResult, SpaceTextDocument } from '../../../shared/spaces/spaceFileApi' +import type { BoundedEntryMutation, BoundedEntryMutationResult } from '../../../platform/filesystem/mutateBoundedEntry' +import type { LocalSpaceDirectoryPage, LocalSpaceFileEntry, LocalSpaceFilePreview, SpaceDirectoryRequest, SpaceFileMutation, SpaceFileMutationResult, SpaceFileTarget, SpaceSaveDocument, SpaceSaveResult, SpaceTextDocument } from '../../../shared/spaces/spaceFileApi' import type { SpaceRepository } from '../storage/spaceRepository' import { createHash, randomUUID } from 'node:crypto' import { readdir, stat } from 'node:fs/promises' import { isAbsolute, resolve } from 'node:path' +import process from 'node:process' import { readBoundedFile } from '../../../platform/filesystem/boundedFile' +import { mutateBoundedEntry } from '../../../platform/filesystem/mutateBoundedEntry' import { saveBoundedTextFile } from '../../../platform/filesystem/saveBoundedTextFile' import { Emitter } from '../../../shared/events/Emitter' +import { validSpaceFileName } from '../../../shared/spaces/spaceFileNames' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { readFilePreview } from '../files/readFilePreview' import { BuddyServiceError } from '../rpc/runtimeRequest' @@ -17,7 +21,7 @@ export interface SpaceFileChange { readonly spaceId: string readonly directoryId: string readonly directoryRevision: number - readonly kind: 'saved' | 'conflict' | 'response-denied' + readonly kind: 'saved' | 'conflict' | 'response-denied' | 'mutated' } export class SpaceFileService { @@ -27,9 +31,49 @@ export class SpaceFileService { #revision = 0 #disposed = false readonly #saves = new Map>() + readonly #mutations = new Map>() - constructor(spaces: Pick) { + readonly #mutateEntry: (input: BoundedEntryMutation, beforeCommit: () => void) => Promise + + constructor(spaces: Pick, mutateEntry: (input: BoundedEntryMutation, beforeCommit: () => void) => Promise = mutateBoundedEntry) { this.#spaces = spaces + this.#mutateEntry = mutateEntry + } + + mutate(input: SpaceFileMutation): Promise { + input = { ...input } + const directory = this.requireDirectory(input) + if (this.#disposed || this.#mutations.has(directory.id) || [...this.#saves.keys()].some(key => JSON.parse(key)[0] === directory.id)) + return Promise.resolve({ status: 'failed', reason: 'busy' }) + const creating = input.operation === 'create-file' || input.operation === 'create-directory' + if (isAbsolute(input.path) || input.path.includes('\\') || input.path.split('/').some(part => part === '.' || part === '..' || (!part && input.path !== '')) || (!creating && !input.path)) + return Promise.resolve({ status: 'failed', reason: 'unsafe-path' }) + if (input.operation !== 'trash' && !validSpaceFileName(input.name, process.platform === 'win32')) + return Promise.resolve({ status: 'failed', reason: 'invalid-name' }) + const oldName = input.path.split('/').at(-1)! + if (input.operation === 'rename' && oldName !== input.name && oldName.toLowerCase() === input.name.toLowerCase()) + return Promise.resolve({ status: 'failed', reason: 'case-only' }) + const operation = Promise.resolve().then(async (): Promise => { + this.requireDirectory(input) + // Pass the lexical path, NOT a realpath that would hide a link ancestor from the helper. + const result = await this.#mutateEntry({ root: directory.canonicalRoot, path: resolve(directory.canonicalRoot, input.path), operation: input.operation, ...('name' in input ? { name: input.name } : {}) }, () => this.requireDirectory(input)) + if (result.status === 'failed') + return result + this.#changes.fire(Object.freeze({ revision: ++this.#revision, operationId: randomUUID(), spaceId: input.spaceId, directoryId: input.directoryId, directoryRevision: input.revision, kind: 'mutated' })) + try { + this.requireDirectory(input) + } + catch { + return { status: 'failed', reason: 'result-unknown' } + } + const parent = input.path.includes('/') ? input.path.slice(0, input.path.lastIndexOf('/')) : '' + const path = creating + ? [input.path, 'name' in input ? input.name : ''].filter(Boolean).join('/') + : input.operation === 'rename' ? [parent, input.name].filter(Boolean).join('/') : parent + return { status: 'completed', path, kind: result.kind } + }).finally(() => this.#mutations.delete(directory.id)) + this.#mutations.set(directory.id, operation) + return operation } async list(input: SpaceDirectoryRequest): Promise { @@ -52,7 +96,7 @@ export class SpaceFileService { unavailable = true } } - return { name: entry.name, path, kind, unavailable } + return { name: entry.name, path, kind, unavailable, writable: !entry.isSymbolicLink() && !unavailable } })) this.requireDirectory(input) return { entries: result, nextCursor: cursorIndex + 1 + page.length < entries.length ? page.at(-1)?.name ?? null : null } @@ -76,6 +120,8 @@ export class SpaceFileService { } async readDocument(input: SpaceFileTarget): Promise { + if (this.#mutations.has(input.directoryId)) + throw new BuddyServiceError('VALIDATION_FAILED') const target = await this.resolve(input) const bytes = await readBoundedFile(target.root, target.path, 1024 * 1024) this.requireDirectory(input) @@ -86,7 +132,7 @@ export class SpaceFileService { } saveDocument(input: SpaceSaveDocument): Promise { - if (this.#disposed) + if (this.#disposed || this.#mutations.has(input.directoryId)) return Promise.reject(new Error('SPACE_FILES_STOPPED')) input = { ...input } const operationId = randomUUID() @@ -123,7 +169,7 @@ export class SpaceFileService { async dispose(): Promise { this.#disposed = true - await Promise.allSettled([...this.#saves.values()]) + await Promise.allSettled([...this.#saves.values(), ...this.#mutations.values()]) this.#changes.dispose() } diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileMutation.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileMutation.spec.ts new file mode 100644 index 00000000..875d8fbe --- /dev/null +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileMutation.spec.ts @@ -0,0 +1,178 @@ +import type { BoundedEntryMutation, BoundedEntryMutationResult } from '../../../../platform/filesystem/mutateBoundedEntry' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mutateBoundedEntry } from '../../../../platform/filesystem/mutateBoundedEntry' +import { openBuddyDatabase } from '../../storage/database' +import { createSpaceRepository } from '../../storage/spaceRepository' +import { SpaceFileService } from '../SpaceFileService' +import { SpaceService } from '../SpaceService' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const dispose of cleanup.splice(0)) + await dispose() +}) +async function fixture(mutator?: ConstructorParameters[1]) { + const root = await mkdtemp(join(tmpdir(), 'lexora-file-mutation-')) + const workspace = join(root, 'workspace') + await mkdir(workspace) + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const repository = createSpaceRepository(database) + const spaces = new SpaceService(repository) + const space = await spaces.create({ name: 'Files', memoryScope: 'space_only', primaryDirectory: { id: null, root: workspace }, primaryDirectorySelectionVerified: true }) + const files = new SpaceFileService(repository, mutator) + cleanup.push(async () => { + await files.dispose() + database.close() + await rm(root, { recursive: true, force: true }) + }) + const target = { spaceId: space.id, directoryId: space.primaryDirectory!.id, revision: space.primaryDirectory!.revision, path: '' } + return { database, root, workspace, files, spaces, target } +} + +describe('space file mutation boundary', () => { + it('refuses traversal, root rename/delete, invalid names and stale grants before dispatch', async () => { + const adapter = vi.fn(async () => ({ status: 'completed', kind: 'file' } as const)) + const f = await fixture(adapter) + for (const path of ['../outside', '/outside', 'a/../b', 'a\\b', 'a//b', './a']) + expect(await f.files.mutate({ ...f.target, path, operation: 'create-file', name: 'note' })).toEqual({ status: 'failed', reason: 'unsafe-path' }) + expect(await f.files.mutate({ ...f.target, operation: 'rename', name: 'new-root' })).toMatchObject({ reason: 'unsafe-path' }) + expect(await f.files.mutate({ ...f.target, operation: 'trash' })).toMatchObject({ reason: 'unsafe-path' }) + expect(await f.files.mutate({ ...f.target, operation: 'create-file', name: '../note' })).toMatchObject({ reason: 'invalid-name' }) + expect(await f.files.mutate({ ...f.target, path: 'note.md', operation: 'rename', name: 'NOTE.md' })).toMatchObject({ reason: 'case-only' }) + await expect(async () => f.files.mutate({ ...f.target, revision: 99, operation: 'create-file', name: 'note' })).rejects.toThrow() + expect(adapter).not.toHaveBeenCalled() + }) + it('serializes a directory with a simple busy state and blocks concurrent reads/saves', async () => { + let finish!: (result: BoundedEntryMutationResult) => void + const f = await fixture(async (_input, beforeCommit) => { + beforeCommit() + return new Promise(resolve => finish = resolve) + }) + const pending = f.files.mutate({ ...f.target, operation: 'create-file', name: 'one' }) + await Promise.resolve() + expect(await f.files.mutate({ ...f.target, operation: 'create-directory', name: 'two' })).toMatchObject({ reason: 'busy' }) + await expect(f.files.readDocument({ ...f.target, path: 'note' })).rejects.toThrow() + await expect(f.files.saveDocument({ ...f.target, path: 'note', text: '', etag: 'a'.repeat(64) })).rejects.toThrow() + finish({ status: 'completed', kind: 'file' }) + expect(await pending).toMatchObject({ status: 'completed', path: 'one' }) + await expect(f.files.readDocument({ ...f.target, path: 'note' })).rejects.not.toThrow('SPACE_FILES_STOPPED') + }) + it('rechecks authorization immediately before commit', async () => { + let committed = false + const f = await fixture(async (_input, beforeCommit) => { + f.database.prepare('UPDATE space_directory_bindings SET revision = revision + 1 WHERE id = ?').run(f.target.directoryId) + beforeCommit() + committed = true + return { status: 'completed', kind: 'file' } + }) + await expect(f.files.mutate({ ...f.target, operation: 'create-file', name: 'note' })).rejects.toThrow() + expect(committed).toBe(false) + expect(await readdir(f.workspace)).toEqual([]) + }) + it('reports result-unknown, not a rollback, if authorization changes after a committed result', async () => { + const f = await fixture(async (_input, beforeCommit) => { + beforeCommit() + return { status: 'completed', kind: 'file' } + }) + f.files.onDidChange((event) => { + if (event.kind === 'mutated') + f.database.prepare('UPDATE space_directory_bindings SET revision = revision + 1 WHERE id = ?').run(f.target.directoryId) + }) + expect(await f.files.mutate({ ...f.target, operation: 'create-file', name: 'note' })).toEqual({ status: 'failed', reason: 'result-unknown' }) + }) + it('never falls back to deletion when recycling is unavailable', async () => { + const adapter = vi.fn(async (_input: BoundedEntryMutation, beforeCommit: () => void) => { + beforeCommit() + return { status: 'failed', reason: 'unsupported' } as const + }) + const f = await fixture(adapter) + await writeFile(join(f.workspace, 'keep.md'), 'keep') + expect(await f.files.mutate({ ...f.target, path: 'keep.md', operation: 'trash' })).toEqual({ status: 'failed', reason: 'unsupported' }) + expect(await readFile(join(f.workspace, 'keep.md'), 'utf8')).toBe('keep') + expect(adapter).toHaveBeenCalledOnce() + expect(adapter.mock.calls[0]![0].operation).toBe('trash') + }) +}) + +describe.runIf(process.platform === 'win32')('windows native file mutations', () => { + it('recycles a file and a non-empty folder, then restores only these temporary fixtures', async () => { + const f = await fixture() + await writeFile(join(f.workspace, 'recover-中文😀.md'), 'recover file') + await mkdir(join(f.workspace, 'recover-folder')) + await writeFile(join(f.workspace, 'recover-folder', 'child.md'), 'recover child') + const outside = join(f.root, 'outside') + await mkdir(outside) + await writeFile(join(outside, 'keep.md'), 'outside remains untouched') + await symlink(outside, join(f.workspace, 'recover-folder', 'outside-link'), 'junction') + const results = [] + try { + results.push(await f.files.mutate({ ...f.target, path: 'recover-中文😀.md', operation: 'trash' })) + results.push(await f.files.mutate({ ...f.target, path: 'recover-folder', operation: 'trash' })) + expect(await readdir(f.workspace)).toEqual([]) + expect(await readFile(join(outside, 'keep.md'), 'utf8')).toBe('outside remains untouched') + } + finally { + // Only items deleted from this test's unique temporary directory may be restored. + // Never empty the Recycle Bin or enumerate personal file contents. + const { stdout } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ` + $ErrorActionPreference = 'Stop' + $shell = New-Object -ComObject Shell.Application + $items = @($shell.Namespace(10).Items() | Where-Object { $_.ExtendedProperty('System.Recycle.DeletedFrom') -eq $env:LEXORA_RECYCLE_FIXTURE }) + foreach ($item in $items) { $item.InvokeVerb('undelete') } + $deadline = (Get-Date).AddSeconds(10) + while ((!(Test-Path (Join-Path $env:LEXORA_RECYCLE_FIXTURE 'recover-中文😀.md')) -or !(Test-Path (Join-Path $env:LEXORA_RECYCLE_FIXTURE 'recover-folder/child.md'))) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 100 } + Write-Output $items.Count + `], { timeout: 15_000, windowsHide: true, env: { ...process.env, LEXORA_RECYCLE_FIXTURE: f.workspace } }) + expect(stdout.trim()).toBe('2') + } + expect(results).toEqual([{ status: 'completed', path: '', kind: 'file' }, { status: 'completed', path: '', kind: 'directory' }]) + expect(await readFile(join(f.workspace, 'recover-中文😀.md'), 'utf8')).toBe('recover file') + expect(await readFile(join(f.workspace, 'recover-folder', 'child.md'), 'utf8')).toBe('recover child') + expect(await readFile(join(outside, 'keep.md'), 'utf8')).toBe('outside remains untouched') + }, 25_000) + it('creates and renames files and non-empty directories without covering existing entries', async () => { + const f = await fixture() + expect(await f.files.mutate({ ...f.target, operation: 'create-file', name: 'one.md' })).toEqual({ status: 'completed', path: 'one.md', kind: 'file' }) + expect(await readFile(join(f.workspace, 'one.md'), 'utf8')).toBe('') + await writeFile(join(f.workspace, 'one.md'), 'keep') + expect(await f.files.mutate({ ...f.target, operation: 'create-file', name: 'one.md' })).toMatchObject({ reason: 'exists' }) + expect(await f.files.mutate({ ...f.target, operation: 'create-file', name: 'two.md' })).toMatchObject({ status: 'completed' }) + expect(await f.files.mutate({ ...f.target, path: 'one.md', operation: 'rename', name: 'two.md' })).toMatchObject({ reason: 'exists' }) + expect(await readFile(join(f.workspace, 'one.md'), 'utf8')).toBe('keep') + expect(await f.files.mutate({ ...f.target, path: 'one.md', operation: 'rename', name: 'renamed.md' })).toEqual({ status: 'completed', path: 'renamed.md', kind: 'file' }) + expect(await f.files.mutate({ ...f.target, operation: 'create-directory', name: 'folder' })).toMatchObject({ status: 'completed' }) + expect(await f.files.mutate({ ...f.target, path: 'folder', operation: 'create-file', name: 'child.md' })).toMatchObject({ path: 'folder/child.md' }) + expect(await f.files.mutate({ ...f.target, path: 'folder', operation: 'rename', name: 'new-folder' })).toMatchObject({ path: 'new-folder', kind: 'directory' }) + expect(await readFile(join(f.workspace, 'new-folder', 'child.md'), 'utf8')).toBe('') + }) + it('rejects junction entries and junction ancestors even when they lead inside the root', async () => { + const f = await fixture() + await mkdir(join(f.workspace, 'real')) + await writeFile(join(f.workspace, 'real', 'keep.md'), 'keep') + await symlink(join(f.workspace, 'real'), join(f.workspace, 'link'), 'junction') + expect((await f.files.list(f.target)).entries.find(entry => entry.name === 'link')).toMatchObject({ writable: false }) + for (const mutation of [ + { ...f.target, path: 'link', operation: 'rename', name: 'wrong' } as const, + { ...f.target, path: 'link', operation: 'trash' } as const, + { ...f.target, path: 'link', operation: 'create-file', name: 'wrong' } as const, + { ...f.target, path: 'link/keep.md', operation: 'rename', name: 'wrong' } as const, + ]) + expect(await f.files.mutate(mutation)).toMatchObject({ status: 'failed', reason: 'unsafe-path' }) + expect(await readFile(join(f.workspace, 'real', 'keep.md'), 'utf8')).toBe('keep') + expect(await readdir(join(f.workspace, 'real'))).toEqual(['keep.md']) + }) + it('cancels the prepared native request if the grant is revoked during its final check', async () => { + const f = await fixture((input, beforeCommit) => mutateBoundedEntry(input, () => { + f.database.prepare('UPDATE space_directory_bindings SET revision = revision + 1 WHERE id = ?').run(f.target.directoryId) + beforeCommit() + })) + await expect(f.files.mutate({ ...f.target, operation: 'create-file', name: 'never-created' })).rejects.toThrow() + expect(await readdir(f.workspace)).toEqual([]) + }) +}) diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index 5ba1a535..a852163a 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import process from 'node:process' import { afterEach, describe, expect, it } from 'vitest' import { openBuddyDatabase } from '../../storage/database' import { createSpaceRepository } from '../../storage/spaceRepository' @@ -89,9 +90,21 @@ describe('space file browsing', () => { const f = await fixture() await writeFile(join(f.root, 'outside.txt'), 'outside') await writeFile(join(f.workspace, 'inside.txt'), 'inside') - await symlink(join(f.root, 'outside.txt'), join(f.workspace, 'escape')) + // Windows directory junctions do not require the symlink creation privilege. + // Keep file-symlink coverage on other platforms and exercise directory traversal on Windows. + let escapePath = 'escape' + if (process.platform === 'win32') { + const outsideDirectory = join(f.root, 'outside-directory') + await mkdir(outsideDirectory) + await writeFile(join(outsideDirectory, 'outside.txt'), 'outside') + await symlink(outsideDirectory, join(f.workspace, 'escape'), 'junction') + escapePath = 'escape/outside.txt' + } + else { + await symlink(join(f.root, 'outside.txt'), join(f.workspace, 'escape')) + } expect((await f.files.list(f.target)).entries.find(entry => entry.name === 'escape')).toMatchObject({ unavailable: true }) - for (const path of ['../outside.txt', join(f.root, 'outside.txt'), 'escape']) + for (const path of ['../outside.txt', join(f.root, 'outside.txt'), escapePath]) await expect(f.files.read({ ...f.target, path })).rejects.toThrow() await expect(f.files.read({ ...f.target, path: 'inside.txt', revision: 99 })).rejects.toThrow() await f.spaces.delete(f.target.spaceId) diff --git a/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts b/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts index d505b4bc..06e3f544 100644 --- a/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts +++ b/apps/buddy/service/src/spaces/registerSpaceFileRpc.ts @@ -5,6 +5,7 @@ import { registerRuntimeRequest } from '../rpc/runtimeRequest' export function registerSpaceFileRpc(rpc: RuntimeRequestRegistrar, files: SpaceFileService): () => void { const disposers = [ + registerRuntimeRequest(rpc, spaceFilesRpc.mutate, input => files.mutate(input)), registerRuntimeRequest(rpc, spaceFilesRpc.readDocument, input => files.readDocument(input)), registerRuntimeRequest(rpc, spaceFilesRpc.saveDocument, input => files.saveDocument(input)), registerRuntimeRequest(rpc, spaceFilesRpc.list, input => files.list(input)), diff --git a/apps/buddy/shared/spaces/__tests__/spaceFileNames.spec.ts b/apps/buddy/shared/spaces/__tests__/spaceFileNames.spec.ts new file mode 100644 index 00000000..7614d447 --- /dev/null +++ b/apps/buddy/shared/spaces/__tests__/spaceFileNames.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { spaceFileMutationSchema } from '../spaceFileApi' +import { pathInFileScope, validSpaceFileName } from '../spaceFileNames' + +describe('workspace file names and boundaries', () => { + it.each(['', '.', '..', 'a/b', 'a\\b', 'NUL', 'con.txt', 'COM1', 'lpt¹.txt', 'CONIN$', 'a:b', 'a?b', 'a.', 'a ', `a${String.fromCharCode(0)}b`, `a${String.fromCharCode(31)}b`])('rejects %j without rewriting it', (name) => { + expect(validSpaceFileName(name)).toBe(false) + }) + it.each(['note.md', '你好.txt', '.gitignore', 'new folder', 'COM10.txt', 'file😀.md'])('accepts %j', (name) => { + expect(validSpaceFileName(name)).toBe(true) + }) + it('matches descendants only at path boundaries', () => { + expect(pathInFileScope('src', 'src')).toBe(true) + expect(pathInFileScope('src/child/a.ts', 'src')).toBe(true) + expect(pathInFileScope('src-other/a.ts', 'src')).toBe(false) + expect(pathInFileScope('a.ts', '')).toBe(false) + }) + it('accepts only bounded operation payloads, not absolute-path or command parameters', () => { + const target = { spaceId: 'space', directoryId: 'directory', revision: 1, path: '' } + expect(spaceFileMutationSchema.safeParse({ ...target, operation: 'create-file', name: 'file' }).success).toBe(true) + expect(spaceFileMutationSchema.safeParse({ ...target, operation: 'trash', absolutePath: 'C:/private' }).success).toBe(false) + expect(spaceFileMutationSchema.safeParse({ ...target, operation: 'shell', command: 'anything' }).success).toBe(false) + expect(spaceFileMutationSchema.safeParse({ ...target, operation: 'trash', name: 'anything' }).success).toBe(false) + }) +}) diff --git a/apps/buddy/shared/spaces/spaceFileApi.ts b/apps/buddy/shared/spaces/spaceFileApi.ts index b7be47e7..a42db369 100644 --- a/apps/buddy/shared/spaces/spaceFileApi.ts +++ b/apps/buddy/shared/spaces/spaceFileApi.ts @@ -11,8 +11,8 @@ export const spaceFileTargetSchema = z.object({ path: z.string().max(4096), }).strict() -export const spaceFileEntrySchema = fileEntrySchema -export const spaceDirectoryPageSchema = directoryPageSchema +export const spaceFileEntrySchema = fileEntrySchema.extend({ writable: z.boolean().optional() }) +export const spaceDirectoryPageSchema = directoryPageSchema.extend({ entries: z.array(spaceFileEntrySchema).max(250) }) export const spaceFilePreviewSchema = filePreviewSchema export const spaceTextDocumentSchema = z.object({ @@ -44,7 +44,23 @@ export type SpaceDirectoryRequest = z.infer export const spaceDirectoryRequestSchema = spaceFileTargetSchema.extend({ cursor: z.string().max(512).optional() }).strict() +export const spaceFileMutationSchema = z.discriminatedUnion('operation', [ + spaceFileTargetSchema.extend({ operation: z.literal('create-file'), name: z.string().max(1024) }).strict(), + spaceFileTargetSchema.extend({ operation: z.literal('create-directory'), name: z.string().max(1024) }).strict(), + spaceFileTargetSchema.extend({ operation: z.literal('rename'), name: z.string().max(1024) }).strict(), + spaceFileTargetSchema.extend({ operation: z.literal('trash') }).strict(), +]) +export const spaceFileMutationErrorSchema = z.enum(['invalid-name', 'exists', 'missing', 'unsafe-path', 'permission', 'busy', 'unsupported', 'case-only', 'open-resource', 'result-unknown', 'failed']) +export const spaceFileMutationResultSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('completed'), path: z.string().max(4096), kind: z.enum(['file', 'directory']) }).strict(), + z.object({ status: z.literal('failed'), reason: spaceFileMutationErrorSchema }).strict(), +]) +export type SpaceFileMutation = z.infer +export type SpaceFileMutationResult = z.infer +export type SpaceFileMutationError = z.infer + export const spaceFilesRpc = { + mutate: { method: 'spaceFiles.mutate', input: spaceFileMutationSchema, response: spaceFileMutationResultSchema }, readDocument: { method: 'spaceFiles.readDocument', input: spaceFileTargetSchema, response: spaceTextDocumentSchema }, saveDocument: { method: 'spaceFiles.saveDocument', input: spaceSaveDocumentSchema, response: spaceSaveResultSchema }, list: { method: 'spaceFiles.list', input: spaceDirectoryRequestSchema, response: spaceDirectoryPageSchema }, diff --git a/apps/buddy/shared/spaces/spaceFileNames.ts b/apps/buddy/shared/spaces/spaceFileNames.ts new file mode 100644 index 00000000..e1218a10 --- /dev/null +++ b/apps/buddy/shared/spaces/spaceFileNames.ts @@ -0,0 +1,15 @@ +// Shared by the name dialog and the trusted service; neither silently rewrites input. +export function validSpaceFileName(name: string, windows = true): boolean { + if (!name || name === '.' || name === '..' || /[/\\]/u.test(name) || [...name].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) + return false + if (!windows) + return true + if (/[<>:"|?*]/u.test(name) || /[. ]$/u.test(name) || name.length > 255) + return false + const stem = name.split('.')[0]!.trimEnd().toUpperCase() + return !/^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|(?:COM|LPT)[1-9¹²³])$/u.test(stem) +} + +export function pathInFileScope(path: string, scope: string): boolean { + return path === scope || (scope !== '' && path.startsWith(`${scope}/`)) +} diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 14624c75..620bfd09 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -216,7 +216,7 @@ const shellBindings: DesktopShellBindings = { resources, resourceContext: { getChangeOverview: api.localChat.changes.overview, - files: { listDirectory: api.localChat.spaces.listDirectory, readFile: api.localChat.spaces.readFile, revealFile: api.localChat.spaces.revealFile }, + files: workbench.workspaceFiles, getNodeDetail: api.localChat.conversations.getNodeDetail, getChangeSet: api.localChat.changes.get, readArtifactText: api.localChat.artifacts.readText, diff --git a/apps/buddy/src/app/workbench/WorkbenchDiagnostics.ts b/apps/buddy/src/app/workbench/WorkbenchDiagnostics.ts index bb1869ee..316d789b 100644 --- a/apps/buddy/src/app/workbench/WorkbenchDiagnostics.ts +++ b/apps/buddy/src/app/workbench/WorkbenchDiagnostics.ts @@ -17,7 +17,7 @@ const copyEvents = { 'save-failed': 'save_failed', 'conflict-resolved': 'conflict_resolved', 'discarded': 'discarded', -} as const satisfies Record +} as const satisfies Record, string> const layoutEvents = { 'opened': 'workbench.layout.opened', @@ -33,6 +33,8 @@ export class WorkbenchDiagnostics { constructor({ controller, copies, events }: { controller: WorkbenchController, copies: WorkingCopyService, events: ApplicationEvents }) { this.#subscriptions = [ copies.onDidChange((change) => { + if (change.kind === 'access-changed') + return if (change.kind === 'edited' && change.copy.dirty === change.previous?.dirty) return events.publish({ diff --git a/apps/buddy/src/app/workbench/WorkspaceFileMutationGuard.ts b/apps/buddy/src/app/workbench/WorkspaceFileMutationGuard.ts new file mode 100644 index 00000000..4517eff5 --- /dev/null +++ b/apps/buddy/src/app/workbench/WorkspaceFileMutationGuard.ts @@ -0,0 +1,44 @@ +import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' +import type { ResourceRef } from '@/workbench/common/workbench' +import { pathInFileScope } from '@buddy-shared/spaces/spaceFileNames' + +export function resourceInFileScope(resource: ResourceRef, target: SpaceFileTarget): boolean { + if (resource.scheme !== 'file' && resource.scheme !== 'file-preview') + return false + const data = resource.data + return data.spaceId === target.spaceId && data.directoryId === target.directoryId && data.revision === target.revision + && typeof data.path === 'string' && pathInFileScope(data.path, target.path) +} + +// Local synchronous gate, not a scheduler. The same predicate is checked by editor and view entry points. +export class WorkspaceFileMutationGuard { + readonly #targets = new Set() + allowed(resource: ResourceRef): boolean { + return ![...this.#targets].some(target => resourceInFileScope(resource, target)) + } + + acquire(target: SpaceFileTarget, resources: () => readonly ResourceRef[], destination?: SpaceFileTarget): (() => void) | null { + if ([...this.#targets].some(other => other.directoryId === target.directoryId)) + return null + const captured = { ...target } + this.#targets.add(captured) + try { + if (resources().some(resource => resourceInFileScope(resource, captured))) { + this.#targets.delete(captured) + return null + } + } + catch (error) { + this.#targets.delete(captured) + throw error + } + const next = destination ? { ...destination } : null + if (next) + this.#targets.add(next) + return () => { + this.#targets.delete(captured) + if (next) + this.#targets.delete(next) + } + } +} diff --git a/apps/buddy/src/app/workbench/WorkspaceFileOperations.ts b/apps/buddy/src/app/workbench/WorkspaceFileOperations.ts new file mode 100644 index 00000000..9b3b0437 --- /dev/null +++ b/apps/buddy/src/app/workbench/WorkspaceFileOperations.ts @@ -0,0 +1,130 @@ +import type { SpaceFileMutation, SpaceFileMutationResult, SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' +import type { WorkspaceFileMutationGuard } from './WorkspaceFileMutationGuard' +import type { WorkspaceEntryChange, WorkspaceMutationResult } from '@/modules/tasks/contracts' +import type { ResourceRef } from '@/workbench/common/workbench' +import type { WorkbenchController } from '@/workbench/services/WorkbenchController' +import type { WorkingCopyService } from '@/workbench/services/WorkingCopyService' +import { resourceInFileScope } from './WorkspaceFileMutationGuard' + +export type DirtyFileChoice = 'save' | 'discard' | 'cancel' + +export function renamedFileResource(resource: ResourceRef, source: SpaceFileTarget, path: string): ResourceRef { + const next = path + String(resource.data.path).slice(source.path.length) + return { ...resource, id: JSON.stringify([source.directoryId, source.revision, next]), data: { ...resource.data, path: next } } +} + +export class WorkspaceFileOperations { + constructor(readonly options: { + guard: WorkspaceFileMutationGuard + copies: WorkingCopyService + controller: WorkbenchController + mutate: (input: SpaceFileMutation) => Promise + confirmDirty: (paths: readonly string[]) => Promise + retain: (resource: ResourceRef) => () => void + flush: () => Promise + changed: (change: WorkspaceEntryChange) => void + synchronize: (target: SpaceFileTarget, renamedPath?: string) => void + report: (error: unknown) => void + }) {} + + async mutate(input: SpaceFileMutation): Promise { + const { guard, copies, controller } = this.options + if (input.operation !== 'rename' && input.operation !== 'trash') { + const createdPath = input.path ? `${input.path}/${input.name}` : input.name + if (!guard.allowed({ scheme: 'file', id: '', data: { ...input } }) || !guard.allowed({ scheme: 'file', id: '', data: { ...input, path: createdPath } })) + return { status: 'failed', reason: 'busy' } + const result = await this.options.mutate(input) + if (result.status === 'completed' || result.reason === 'result-unknown') + this.options.changed({ target: input }) + return result + } + const parent = input.path.includes('/') ? input.path.slice(0, input.path.lastIndexOf('/') + 1) : '' + const destination = input.operation === 'rename' ? { ...input, path: parent + input.name } : undefined + if (destination?.path === input.path) + return { status: 'cancelled' } + // Pending opens are not migrated. They must finish before capturing the stable set. + const release = guard.acquire(input, () => [...controller.navigation.entries.values()].map(entry => entry.view.resource), destination) + if (!release) + return { status: 'failed', reason: 'busy' } + const matches = (resource: ResourceRef) => resourceInFileScope(resource, input) || (!!destination && resourceInFileScope(resource, destination)) + const holds: (() => void)[] = [] + let uncertain = false + const lease = copies.beginMutation(matches) + if (!lease) { + release() + return { status: 'failed', reason: 'busy' } + } + try { + if (destination && ([...copies.copies.values()].some(copy => resourceInFileScope(copy.resource, destination)) + || controller.renderedViews.some(view => resourceInFileScope(view.resource, destination)))) { + return { status: 'failed', reason: 'destination-open' } + } + const affected = [...copies.copies.values()].filter(copy => resourceInFileScope(copy.resource, input)) + const views = Object.values(controller.layout.views).filter(view => resourceInFileScope(view.resource, input)) + for (const copy of affected) holds.push(this.options.retain(copy.resource)) + const dirty = affected.filter(copy => copy.dirty) + if (input.operation === 'trash' && dirty.length) { + const choice = await this.options.confirmDirty(dirty.map(copy => String(copy.resource.data.path))) + if (choice === 'cancel') + return { status: 'cancelled' } + if (choice === 'save') { + for (const copy of dirty) { + const result = await lease.save(copy.resource) + if (result.status === 'conflict') + return { status: 'failed', reason: 'save-conflict' } + if ((result.status !== 'saved' && result.status !== 'unchanged') || result.dirtyAfter) + return { status: 'failed', reason: 'save-failed' } + } + } + } + // Persist recovery contents before touching disk. A failure here is still reversible. + await this.options.flush() + let result: SpaceFileMutationResult + try { + result = await this.options.mutate(input) + } + catch { result = { status: 'failed', reason: 'result-unknown' } } + if (result.status === 'failed') { + uncertain = result.reason === 'result-unknown' + if (uncertain) + this.options.changed({ target: input, removedPath: input.path }) + return result + } + // From here disk has changed: never report an ordinary retryable failure or restore old paths. + uncertain = true + if (input.operation === 'rename') { + const moves = affected.map(copy => ({ from: copy.resource, to: renamedFileResource(copy.resource, input, result.path) })) + for (const move of moves) holds.push(this.options.retain(move.to)) + lease.relocate(moves) + controller.commitFileMutation(views.map((view) => { + const resource = renamedFileResource(view.resource, input, result.path) + return { id: view.id, resource, title: String(resource.data.path).split('/').at(-1)! } + })) + } + else { + controller.commitFileMutation([], views.map(view => view.id)) + for (const copy of affected) lease.remove(copy.resource) + } + this.options.synchronize(input, input.operation === 'rename' ? result.path : undefined) + this.options.changed({ target: input, removedPath: input.path }) + uncertain = false + release() + lease.release() + // A persistence error does not undo a successful filesystem mutation. + await this.options.flush().catch(this.options.report) + return result + } + catch (error) { + this.options.report(error) + return { status: 'failed', reason: uncertain ? 'result-unknown' : 'failed' } + } + finally { + // Unknown outcomes retain the write fence and all recovery contents, not a writable old path. + if (!uncertain) { + release() + lease.release() + for (const hold of holds) hold() + } + } + } +} diff --git a/apps/buddy/src/app/workbench/__tests__/WorkspaceFileMutationGuard.spec.ts b/apps/buddy/src/app/workbench/__tests__/WorkspaceFileMutationGuard.spec.ts new file mode 100644 index 00000000..d2363760 --- /dev/null +++ b/apps/buddy/src/app/workbench/__tests__/WorkspaceFileMutationGuard.spec.ts @@ -0,0 +1,80 @@ +import type { ResourceRef } from '@/workbench/common/workbench' +import { deferred } from '@buddy-tests/deferred' +import { describe, expect, it } from 'vitest' +import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' +import { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { WorkingCopyService } from '@/workbench/services/WorkingCopyService' +import { WorkspaceFileMutationGuard } from '../WorkspaceFileMutationGuard' + +const target = { spaceId: 'space', directoryId: 'directory', revision: 1, path: 'src' } +const resource: ResourceRef = { scheme: 'file', id: 'one', data: { ...target, path: 'src/file.ts' } } + +describe('local workspace mutation gate', () => { + it('blocks any opened resource, including a clean editor or preview, without dropping it', () => { + const guard = new WorkspaceFileMutationGuard() + expect(guard.acquire(target, () => [resource])).toBeNull() + expect(guard.allowed(resource)).toBe(true) + expect(guard.acquire(target, () => [{ ...resource, scheme: 'file-preview' }])).toBeNull() + expect(resource.data.path).toBe('src/file.ts') + }) + it('blocks only the captured directory boundary and releases after the operation', () => { + const guard = new WorkspaceFileMutationGuard() + const release = guard.acquire(target, () => [])! + target.path = 'changed-after-capture' + expect(guard.allowed(resource)).toBe(false) + expect(guard.acquire({ ...target, path: 'other' }, () => [])).toBeNull() + expect(guard.allowed({ ...resource, data: { ...resource.data, path: 'src-other/file.ts' } })).toBe(true) + expect(guard.allowed({ ...resource, data: { ...resource.data, revision: 2 } })).toBe(true) + release() + release() + expect(guard.allowed(resource)).toBe(true) + target.path = 'src' + }) + it('protects open, edit and save entry points and resumes them after release', async () => { + const guard = new WorkspaceFileMutationGuard() + const copies = new WorkingCopyService({ canAccess: value => guard.allowed(value), read: async () => ({ text: 'disk', etag: 'a' }), save: async (_, document) => ({ status: 'saved', document }) }) + await copies.open(resource) + const release = guard.acquire(target, () => [])! + await expect(copies.open(resource)).rejects.toThrow('WORKING_COPY_BLOCKED') + copies.edit(resource, 'blocked text') + expect(copies.get(resource)?.text).toBe('disk') + expect(await copies.save(resource)).toMatchObject({ status: 'unavailable', reason: 'blocked' }) + release() + copies.edit(resource, 'allowed text') + expect((await copies.save(resource)).status).toBe('saved') + await copies.dispose() + }) + it('rejects opening a workbench view during a mutation', async () => { + const guard = new WorkspaceFileMutationGuard() + const registry = new ContributionRegistry() + registry.register('fixture', scope => scope.view({ id: 'files', renderer: 'fixture', label: 'File', locations: ['main'], multiple: true, supports: value => value.scheme === 'file' })) + const controller = new WorkbenchController(registry, undefined, undefined, value => guard.allowed(value)) + const release = guard.acquire(target, () => [])! + expect(await controller.open(resource, 'file')).toBeNull() + expect(Object.values(controller.layout.views)).toHaveLength(0) + release() + expect(await controller.open(resource, 'file')).toBeTruthy() + await controller.dispose() + }) + it('rechecks a pending open after its asynchronous close preparation', async () => { + const guard = new WorkspaceFileMutationGuard() + const registry = new ContributionRegistry() + registry.register('fixture', scope => scope.view({ id: 'files', renderer: 'fixture', label: 'File', locations: ['main'], multiple: true, supports: value => value.scheme === 'file' })) + const entered = deferred() + const decision = deferred() + const controller = new WorkbenchController(registry, async () => { + entered.resolve() + return decision.promise + }, undefined, value => guard.allowed(value)) + const previous = await controller.open({ ...resource, id: 'other', data: { ...resource.data, path: 'unaffected.md' } }, 'Other') + const pending = controller.open(resource, 'File') + await entered.promise + const release = guard.acquire(target, () => controller.renderedViews.map(view => view.resource))! + expect(release).toBeTruthy() + decision.resolve(true) + expect(await pending).toBeNull() + expect(Object.keys(controller.layout.views)).toEqual([previous]) + release() + await controller.dispose() + }) +}) diff --git a/apps/buddy/src/app/workbench/__tests__/WorkspaceFileOperations.spec.ts b/apps/buddy/src/app/workbench/__tests__/WorkspaceFileOperations.spec.ts new file mode 100644 index 00000000..aea062c5 --- /dev/null +++ b/apps/buddy/src/app/workbench/__tests__/WorkspaceFileOperations.spec.ts @@ -0,0 +1,216 @@ +import type { SpaceFileMutationResult } from '@buddy-shared/spaces/spaceFileApi' +import type { DirtyFileChoice } from '../WorkspaceFileOperations' +import type { ResourceRef } from '@/workbench/common/workbench' +import { deferred } from '@buddy-tests/deferred' +import { describe, expect, it, vi } from 'vitest' +import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' +import { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { WorkingCopyBackup } from '@/workbench/services/WorkingCopyBackup' +import { WorkingCopyService } from '@/workbench/services/WorkingCopyService' +import { WorkspaceFileMutationGuard } from '../WorkspaceFileMutationGuard' +import { WorkspaceFileOperations } from '../WorkspaceFileOperations' + +const target = { spaceId: 'space', directoryId: 'directory', revision: 1, path: 'src' } +function resource(path = 'src/note.md', scheme = 'file'): ResourceRef { + return { scheme, id: JSON.stringify([target.directoryId, target.revision, path]), data: { ...target, path } } +} +function fixture() { + const guard = new WorkspaceFileMutationGuard() + const read = vi.fn(async () => ({ text: 'disk', etag: 'a' })) + const save = vi.fn(async (_: ResourceRef, document: { text: string, etag: string }) => ({ status: 'saved' as 'saved' | 'conflict', document })) + const copies = new WorkingCopyService({ canAccess: value => guard.allowed(value), read, save }) + const backups = new WorkingCopyBackup(copies, async () => {}) + const registry = new ContributionRegistry() + registry.register('fixture', scope => scope.view({ id: 'file', renderer: 'file', label: 'File', locations: ['context'], multiple: true, supports: () => true })) + const controller = new WorkbenchController(registry, async view => guard.allowed(view.resource), undefined, value => guard.allowed(value)) + const mutate = vi.fn(async (): Promise => ({ status: 'completed', path: 'renamed', kind: 'directory' })) + const confirmDirty = vi.fn(async (): Promise => 'discard') + const flush = vi.fn(async () => {}) + const changed = vi.fn() + const synchronize = vi.fn() + const report = vi.fn() + const operations = new WorkspaceFileOperations({ guard, copies, controller, mutate, confirmDirty, flush, changed, synchronize, report, retain: () => () => {} }) + async function open(path = 'src/note.md', dirty = false, scheme = 'file') { + const ref = resource(path, scheme) + if (scheme === 'file') { + await copies.open(ref) + if (dirty) + copies.edit(ref, 'unsaved') + } + const id = await controller.open(ref, path, { duplicate: true }) + return { ref, id: id! } + } + return { operations, copies, backups, controller, mutate, confirmDirty, read, save, flush, changed, synchronize, report, open, guard } +} + +describe('opened workspace file operations', () => { + it('renames dirty descendants and every view, retaining text, base, etag and backups at the new path', async () => { + const f = fixture() + const a = await f.open(undefined, true) + const b = await f.open() + const other = await f.open('src-other/note.md', true) + const preview = await f.open('src/image.png', false, 'file-preview') + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ status: 'completed' }) + expect(f.copies.get(a.ref)).toBeUndefined() + const renamed = resource('renamed/note.md') + expect(f.copies.get(renamed)).toMatchObject({ text: 'unsaved', baseText: 'disk', etag: 'a', dirty: true, blocked: false }) + for (const id of [a.id, b.id]) expect(f.controller.layout.views[id]).toMatchObject({ resource: renamed, title: 'note.md' }) + expect(f.controller.layout.views[preview.id]?.resource.data.path).toBe('renamed/image.png') + expect(f.copies.get(other.ref)?.text).toBe('unsaved') + expect(f.backups.snapshot().backups.map(copy => copy.resource)).toContainEqual(renamed) + expect(f.backups.snapshot().backups.map(copy => copy.resource)).not.toContainEqual(a.ref) + expect(f.confirmDirty).not.toHaveBeenCalled() + await f.copies.save(renamed) + expect(f.save).toHaveBeenCalledWith(renamed, { text: 'unsaved', etag: 'a' }) + }) + it('keeps all editor state and paths after a failed rename', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.mutate.mockResolvedValue({ status: 'failed', reason: 'exists' }) + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ reason: 'exists' }) + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: true, blocked: false }) + expect(f.controller.layout.views[a.id]?.resource).toEqual(a.ref) + expect(f.changed).not.toHaveBeenCalled() + }) + it('cancels trash without saving, deleting, discarding or closing', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.confirmDirty.mockResolvedValue('cancel') + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toEqual({ status: 'cancelled' }) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.save).not.toHaveBeenCalled() + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: true, blocked: false }) + expect(f.controller.layout.views[a.id]).toBeTruthy() + }) + it('discards only after trash succeeds, then closes every descendant view', async () => { + const f = fixture() + const a = await f.open(undefined, true) + const b = await f.open('src/two.md', true) + const outside = await f.open('src-other/file.md') + const done = deferred() + f.mutate.mockReturnValue(done.promise) + const pending = f.operations.mutate({ ...target, operation: 'trash' }) + await vi.waitFor(() => expect(f.mutate).toHaveBeenCalled()) + expect(f.copies.get(a.ref)?.text).toBe('unsaved') + expect(f.controller.layout.views[a.id]).toBeTruthy() + expect(await f.controller.close(a.id)).toMatchObject({ committed: false }) + done.resolve({ status: 'completed', path: '', kind: 'directory' }) + expect(await pending).toMatchObject({ status: 'completed' }) + expect(f.controller.layout.views[a.id]).toBeUndefined() + expect(f.controller.layout.views[b.id]).toBeUndefined() + expect(f.controller.layout.views[outside.id]).toBeTruthy() + expect(f.copies.get(a.ref)).toBeUndefined() + expect(f.backups.snapshot().backups).toHaveLength(0) + expect(f.save).not.toHaveBeenCalled() + }) + it('retains discarded-but-not-yet-deleted text when trash fails', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.mutate.mockResolvedValue({ status: 'failed', reason: 'permission' }) + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'permission' }) + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: true, blocked: false }) + expect(f.controller.layout.views[a.id]).toBeTruthy() + }) + it('saves dirty copies before trash and preserves successful saves if trash fails', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.confirmDirty.mockResolvedValue('save') + f.mutate.mockImplementation(async () => { + expect(f.copies.get(a.ref)?.dirty).toBe(false) + return { status: 'failed', reason: 'busy' } + }) + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'busy' }) + expect(f.save).toHaveBeenCalledOnce() + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: false, blocked: false }) + expect(f.controller.layout.views[a.id]).toBeTruthy() + }) + it('aborts trash on save conflict without losing local content', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.confirmDirty.mockResolvedValue('save') + f.save.mockResolvedValue({ status: 'conflict', document: { text: 'external', etag: 'b' } }) + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'save-conflict' }) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: true, conflict: { text: 'external' }, blocked: false }) + }) + it('aborts trash on save failure without closing editors', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.confirmDirty.mockResolvedValue('save') + f.save.mockRejectedValue(new Error('disk full')) + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'save-failed' }) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.controller.layout.views[a.id]).toBeTruthy() + expect(f.copies.get(a.ref)?.dirty).toBe(true) + }) + it('fences open/edit/save/discard/conflict resolution and both rename paths while pending', async () => { + const f = fixture() + const a = await f.open(undefined, true) + const done = deferred() + f.mutate.mockReturnValue(done.promise) + const pending = f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' }) + await vi.waitFor(() => expect(f.mutate).toHaveBeenCalled()) + f.copies.edit(a.ref, 'lost edit') + f.copies.discard(a.ref) + expect(f.copies.get(a.ref)?.text).toBe('unsaved') + expect(await f.copies.save(a.ref)).toMatchObject({ reason: 'blocked' }) + await expect(f.copies.open(a.ref)).rejects.toThrow('WORKING_COPY_BLOCKED') + expect(await f.controller.open(resource('renamed/note.md'), 'New')).toBeNull() + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'busy' }) + done.resolve({ status: 'completed', path: 'renamed', kind: 'directory' }) + await pending + expect(f.copies.canAccess(resource('renamed/note.md'))).toBe(true) + }) + it.each(['loading', 'saving'] as const)('does not mutate while a copy is %s', async (state) => { + const f = fixture() + const done = deferred<{ text: string, etag: string }>() + let pending: Promise + if (state === 'loading') { + f.read.mockReturnValue(done.promise) + pending = f.copies.open(resource()) + } + else { + await f.open(undefined, true) + f.save.mockImplementation(async () => ({ status: 'saved', document: await done.promise })) + pending = f.copies.save(resource()) + } + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ reason: 'busy' }) + expect(f.mutate).not.toHaveBeenCalled() + done.resolve({ text: 'disk', etag: 'a' }) + await pending + }) + it('does not overwrite a destination working copy', async () => { + const f = fixture() + await f.open(undefined, true) + const destination = await f.open('renamed/note.md', true) + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ reason: 'destination-open' }) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.copies.get(destination.ref)?.text).toBe('unsaved') + }) + it('retains recovery content and fences stale writes on an uncertain outcome', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.mutate.mockRejectedValue(new Error('transport disconnected')) + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ reason: 'result-unknown' }) + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', dirty: true, blocked: true }) + expect(await f.copies.save(a.ref)).toMatchObject({ reason: 'blocked' }) + expect(f.controller.layout.views[a.id]).toBeTruthy() + expect(f.backups.snapshot().backups).toHaveLength(1) + }) + it('does not touch disk when recovery persistence fails', async () => { + const f = fixture() + const a = await f.open(undefined, true) + f.flush.mockRejectedValue(new Error('backup failed')) + expect(await f.operations.mutate({ ...target, operation: 'trash' })).toMatchObject({ reason: 'failed' }) + expect(f.mutate).not.toHaveBeenCalled() + expect(f.copies.get(a.ref)).toMatchObject({ text: 'unsaved', blocked: false }) + }) + it('does not report a retryable failure if post-commit persistence fails', async () => { + const f = fixture() + await f.open(undefined, true) + f.flush.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('backup failed')) + expect(await f.operations.mutate({ ...target, operation: 'rename', name: 'renamed' })).toMatchObject({ status: 'completed' }) + expect(f.copies.get(resource('renamed/note.md'))?.dirty).toBe(true) + expect(f.report).toHaveBeenCalled() + }) +}) diff --git a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts index 18fe6741..51b17a7c 100644 --- a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts +++ b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts @@ -4,15 +4,19 @@ import type { ApplicationEvents } from '@buddy-shared/observability/ApplicationE import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' import type { Router } from 'vue-router' import type { DesktopStores } from '../bootstrap/useDesktopAppState' +import type { DirtyFileChoice } from './WorkspaceFileOperations' import type { TaskIndexController } from '@/modules/tasks' -import type { TaskResourcePanel } from '@/modules/tasks/contracts' +import type { TaskResourcePanel, WorkspaceEntryChange } from '@/modules/tasks/contracts' import type { ChatReadingPositions } from '@/modules/tasks/ui' import type { DropPosition, ResourceRef, SplitDirection, WorkbenchView } from '@/workbench/common/workbench' import type { ViewCloseDecision } from '@/workbench/services/WorkbenchController' import { buddyUserContentToText, getBuddyUserContentResourceIds, hasBuddyUserContent } from '@buddy-shared/conversation/buddyUserContent' +import { Emitter } from '@buddy-shared/events/Emitter' import { isSkillAvailable } from '@buddy-shared/skills/skillApi' +import { pathInFileScope } from '@buddy-shared/spaces/spaceFileNames' import { NButton, useDialog } from 'naive-ui' import { computed, h, onScopeDispose, shallowReactive, shallowRef } from 'vue' +import { useBuddyI18n } from '@/i18n/buddyI18n' import { userContentToChatComposerDocument } from '@/modules/prompt-input' import { TextModelPool } from '@/workbench/browser/TextModelPool' import { ViewRendererRegistry } from '@/workbench/browser/ViewRendererRegistry' @@ -30,22 +34,29 @@ import { TaskWorkspacePool } from './TaskWorkspacePool' import { useTaskInputLifecycle } from './useTaskInputLifecycle' import { WorkbenchDiagnostics } from './WorkbenchDiagnostics' import { WorkbenchResourceLifetime } from './WorkbenchResourceLifetime' +import { resourceInFileScope, WorkspaceFileMutationGuard } from './WorkspaceFileMutationGuard' +import { WorkspaceFileOperations } from './WorkspaceFileOperations' export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: ApplicationEvents, stores: DesktopStores, taskIndex: TaskIndexController, router: Router, resources: () => TaskResourcePanel, onError: (error: unknown) => void }) { const { api, stores, router } = options const dialog = useDialog() const language = stores.applicationSettings.language + const { t } = useBuddyI18n(language) const presentation = computed(() => workbenchLabels(language.value)) const labels = () => presentation.value const readingPositions: ChatReadingPositions = new Map() const backupError = shallowRef(false) const fileToolbarTargets = shallowReactive(new Map()) const openingFiles = new Map>() + const fileMutations = new WorkspaceFileMutationGuard() + const fileEntryChanges = new Emitter(() => console.error('WORKSPACE_FILE_OBSERVER_FAILED')) + onScopeDispose(() => fileEntryChanges.dispose()) const copies = new WorkingCopyService({ + canAccess: resource => fileMutations.allowed(resource), read: resource => api.localChat.spaces.readDocument(resource.data as unknown as SpaceFileTarget), save: (resource, document) => api.localChat.spaces.saveDocument({ ...resource.data as unknown as SpaceFileTarget, ...document }), }) - const controller = new WorkbenchController(new ContributionRegistry(), beforeClose) + const controller = new WorkbenchController(new ContributionRegistry(), beforeClose, undefined, resource => fileMutations.allowed(resource)) const diagnostics = new WorkbenchDiagnostics({ controller, copies, events: options.events }) const models = new TextModelPool(copies) const renderers = new ViewRendererRegistry() @@ -294,7 +305,89 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap async function closeContextFiles(tabId: string) { return (await controller.closeMany(contextViews([tabId]))).status === 'closed' } + + function confirmDirtyFiles(paths: readonly string[]): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (choice: DirtyFileChoice) => { + if (!settled) { + settled = true + resolve(choice) + } + } + const modal = dialog.warning({ + title: t('desktop.context.fileAction.dirtyTrashTitle'), + style: { width: '440px', maxWidth: 'calc(100vw - 32px)' }, + content: () => h('div', [ + h('p', t('desktop.context.fileAction.dirtyTrashHint')), + h('ul', { style: 'max-height:180px;overflow:auto;overflow-wrap:anywhere;padding-left:20px' }, paths.map(path => h('li', path))), + ]), + onClose: () => finish('cancel'), + onMaskClick: () => finish('cancel'), + onEsc: () => finish('cancel'), + action: () => h('div', { style: 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end' }, [ + ...(['cancel', 'discard', 'save'] as const).map(choice => h(NButton, { + size: 'small', + autofocus: choice === 'cancel', + type: choice === 'discard' ? 'error' : choice === 'save' ? 'primary' : 'default', + onClick: () => { + finish(choice) + modal.destroy() + }, + }, () => t(`desktop.context.fileAction.${choice === 'cancel' ? 'cancel' : choice === 'discard' ? 'discardAndTrash' : 'saveAndTrash'}`))), + ]), + }) + }) + } + const fileOperations = new WorkspaceFileOperations({ + guard: fileMutations, + copies, + controller, + mutate: input => api.localChat.spaces.mutateEntry(input), + confirmDirty: confirmDirtyFiles, + retain: resource => resourceLifetime.acquire(resource), + flush: () => persistence.flush(), + changed: change => fileEntryChanges.fire(change), + report: options.onError, + synchronize: (target, renamedPath) => { + for (const tab of options.resources().allTabs.value) { + if (tab.kind === 'files' && tab.target.spaceId === target.spaceId && tab.target.directoryId === target.directoryId && tab.target.revision === target.revision && pathInFileScope(tab.target.path, target.path)) + options.resources().selectFile(tab.id, renamedPath === undefined ? '' : renamedPath + tab.target.path.slice(target.path.length)) + } + }, + }) + async function closeWorkspaceEntries(target: SpaceFileTarget): Promise { + const ids = Object.values(controller.layout.views).filter(view => resourceInFileScope(view.resource, target)).map(view => view.id) + const result = await controller.closeMany(ids) + if (!result.committed || result.status !== 'closed') + return false + for (const tab of options.resources().allTabs.value) { + if (tab.kind === 'files' && tab.target.directoryId === target.directoryId && tab.target.revision === target.revision && pathInFileScope(tab.target.path, target.path)) + options.resources().selectFile(tab.id, '') + } + return true + } + const workspaceFiles = { + listDirectory: api.localChat.spaces.listDirectory, + readFile: api.localChat.spaces.readFile, + revealFile: api.localChat.spaces.revealFile, + locateEntry: api.localChat.spaces.locateEntry, + mutateEntry: (input: Parameters[0]) => fileOperations.mutate(input), + closeEntries: closeWorkspaceEntries, + openEntry: async (target: SpaceFileTarget, tabId: string) => { + const id = await openFile(target, tabId) + if (!id) + return false + const view = controller.layout.views[id] + if (view?.resource.scheme === 'file') + controller.updateView(id, { state: { ...view.state, mode: 'edit' } }) + return true + }, + onEntriesChanged: (listener: (change: WorkspaceEntryChange) => void) => fileEntryChanges.event(listener).dispose, + } function openFile(target: SpaceFileTarget, tabId?: string) { + if (!fileMutations.allowed({ scheme: 'file', id: '', data: { ...target } })) + return Promise.resolve(null) const key = JSON.stringify([tabId, target.directoryId, target.revision, target.path]) const pending = openingFiles.get(key) if (pending) @@ -401,6 +494,8 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap } async function beforeClose(view: WorkbenchView, closing?: ReadonlySet): Promise { + if (!fileMutations.allowed(view.resource)) + return false if (view.resource.scheme === 'task' && deletedTasks.has(view.resource.id)) return true if (view.resource.scheme === 'draft' && !confirmedDraftCloses.has(view.id)) { @@ -543,7 +638,7 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap await inputs.flush().catch(options.onError) return saved } - return { api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { + return { workspaceFiles, api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { return initialized.value }, get navigationVersion() { return navigationVersion diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 63367393..fc6fd9c0 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -111,6 +111,47 @@ export default { 'desktop.context.addTab': 'New tab', 'desktop.context.fileRevealFailed': 'Could not reveal this file', 'desktop.context.revealFile': 'Open containing folder', + 'desktop.context.fileAction.open': 'Open', + 'desktop.context.fileAction.newFile': 'New file', + 'desktop.context.fileAction.newFolder': 'New folder', + 'desktop.context.fileAction.rename': 'Rename', + 'desktop.context.fileAction.trash': 'Move to Recycle Bin', + 'desktop.context.fileAction.copyRelative': 'Copy relative path', + 'desktop.context.fileAction.copyFull': 'Copy full path', + 'desktop.context.fileAction.reveal': 'Show in system file manager', + 'desktop.context.fileAction.openFolder': 'Open in system file manager', + 'desktop.context.fileAction.defaultFile': 'New file.txt', + 'desktop.context.fileAction.defaultFolder': 'New folder', + 'desktop.context.fileAction.name': 'Name', + 'desktop.context.fileAction.cancel': 'Cancel', + 'desktop.context.fileAction.create': 'Create', + 'desktop.context.fileAction.confirmRename': 'Confirm rename', + 'desktop.context.fileAction.extensionWarning': 'Changing the extension may affect how the file opens. Confirm rename to apply this change.', + 'desktop.context.fileAction.deleteFileHint': 'Move this file to the system Recycle Bin?', + 'desktop.context.fileAction.deleteFolderHint': 'Move this folder and all its contents to the system Recycle Bin?', + 'desktop.context.fileAction.closeRelated': 'Close related files', + 'desktop.context.fileAction.trashed': 'Moved to Recycle Bin. You can restore it from the system Recycle Bin.', + 'desktop.context.fileAction.refreshFailedAfterChange': 'The disk operation completed, but refreshing failed. Refresh manually; do not repeat the operation.', + 'desktop.context.fileAction.openFailedAfterCreate': 'The file was created but could not open automatically. Open it from the tree.', + 'desktop.context.fileError.invalid-name': 'Invalid name. Check reserved names, invalid characters and trailing spaces or dots.', + 'desktop.context.fileError.exists': 'A file or folder with this name already exists. Choose another name.', + 'desktop.context.fileError.missing': 'The target no longer exists. Refresh the directory and try again.', + 'desktop.context.fileError.unsafe-path': 'This path contains a link or leaves the authorized directory. Refresh the directory; modification is not allowed.', + 'desktop.context.fileError.permission': 'Permission denied. Check directory permissions.', + 'desktop.context.fileError.busy': 'The target or directory is busy. Close related apps or wait for saving to finish.', + 'desktop.context.fileError.unsupported': 'This platform or filesystem cannot safely perform the operation. No files were changed.', + 'desktop.context.fileError.case-only': 'Case-only renaming is not supported in this version. Use a different name.', + 'desktop.context.fileAction.dirtyTrashTitle': 'Files have unsaved changes', + 'desktop.context.fileAction.dirtyTrashHint': 'These files contain unsaved changes. Save before continuing, or discard changes and move to the Recycle Bin? Cancel keeps the files.', + 'desktop.context.fileAction.saveAndTrash': 'Save and continue', + 'desktop.context.fileAction.discardAndTrash': 'Discard and continue', + 'desktop.context.fileAction.mutationBlocked': 'A file operation is pending; this editor is temporarily read-only. If the result is uncertain, check the directory and Recycle Bin first. Unsaved content is retained.', + 'desktop.context.fileError.save-conflict': 'A file changed externally. Resolve the editor save conflict and try again. Nothing was moved to the Recycle Bin.', + 'desktop.context.fileError.save-failed': 'Not all files could be saved; nothing was moved to the Recycle Bin. Check the editor errors. Files already saved are not rolled back.', + 'desktop.context.fileError.destination-open': 'The destination path has open editing resources. Close its tabs before trying again.', + 'desktop.context.fileError.open-resource': 'Save and close files open in this file or folder, then try again.', + 'desktop.context.fileError.result-unknown': 'The result is uncertain. A refresh was attempted; check the directory and Recycle Bin before another operation.', + 'desktop.context.fileError.failed': 'Operation failed. Refresh and check permissions or files in use.', 'desktop.context.wrap': 'Wrap lines', 'desktop.context.refreshFiles': 'Refresh files', 'desktop.context.retry': 'Retry', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 64762b64..7704a728 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -109,6 +109,47 @@ export default { 'desktop.context.addTab': '新建标签页', 'desktop.context.fileRevealFailed': '无法打开文件所在目录', 'desktop.context.revealFile': '打开所在目录', + 'desktop.context.fileAction.open': '打开', + 'desktop.context.fileAction.newFile': '新建文件', + 'desktop.context.fileAction.newFolder': '新建文件夹', + 'desktop.context.fileAction.rename': '重命名', + 'desktop.context.fileAction.trash': '移到回收站', + 'desktop.context.fileAction.copyRelative': '复制相对路径', + 'desktop.context.fileAction.copyFull': '复制完整路径', + 'desktop.context.fileAction.reveal': '在系统文件管理器中显示', + 'desktop.context.fileAction.openFolder': '在系统文件管理器中打开', + 'desktop.context.fileAction.defaultFile': '新建文件.txt', + 'desktop.context.fileAction.defaultFolder': '新建文件夹', + 'desktop.context.fileAction.name': '名称', + 'desktop.context.fileAction.cancel': '取消', + 'desktop.context.fileAction.create': '创建', + 'desktop.context.fileAction.confirmRename': '确认重命名', + 'desktop.context.fileAction.extensionWarning': '更改扩展名可能影响文件打开方式。确认重命名将应用此更改。', + 'desktop.context.fileAction.deleteFileHint': '将此文件移到系统回收站?', + 'desktop.context.fileAction.deleteFolderHint': '将此文件夹及其全部内容移到系统回收站?', + 'desktop.context.fileAction.closeRelated': '关闭相关文件', + 'desktop.context.fileAction.trashed': '已移到回收站,可在系统回收站中恢复', + 'desktop.context.fileAction.refreshFailedAfterChange': '磁盘操作已完成,但目录刷新失败,请手动刷新,不要重复操作', + 'desktop.context.fileAction.openFailedAfterCreate': '文件已创建,但自动打开失败,请从目录树打开', + 'desktop.context.fileError.invalid-name': '名称无效,请检查保留名称、非法字符及尾部空格或点', + 'desktop.context.fileError.exists': '此位置已有同名文件或文件夹,请使用其他名称', + 'desktop.context.fileError.missing': '目标已不存在,请刷新目录后重试', + 'desktop.context.fileError.unsafe-path': '此路径包含链接或超出授权目录,不允许修改,请刷新目录', + 'desktop.context.fileError.permission': '没有修改权限,请检查目录权限', + 'desktop.context.fileError.busy': '目标或目录正在使用中,请关闭相关程序或等待保存完成', + 'desktop.context.fileError.unsupported': '当前平台或文件系统无法安全执行此操作,未修改文件', + 'desktop.context.fileError.case-only': '第一版不支持仅更改大小写,请使用不同名称', + 'desktop.context.fileAction.dirtyTrashTitle': '文件有未保存的修改', + 'desktop.context.fileAction.dirtyTrashHint': '以下文件有未保存内容。保存后继续,还是放弃这些修改并移到回收站?取消不会删除文件。', + 'desktop.context.fileAction.saveAndTrash': '保存后继续', + 'desktop.context.fileAction.discardAndTrash': '放弃修改并继续', + 'desktop.context.fileAction.mutationBlocked': '文件操作处理中,暂时只读。若操作结果不确定,请先核对目录和回收站;未保存内容仍保留。', + 'desktop.context.fileError.save-conflict': '文件已被外部修改。请解决编辑器中的保存冲突后重试,尚未移到回收站。', + 'desktop.context.fileError.save-failed': '未能保存全部文件,尚未移到回收站。请检查编辑器中的错误;已保存的文件不会回滚。', + 'desktop.context.fileError.destination-open': '新名称对应的路径已有编辑资源,请先关闭目标路径的标签后重试', + 'desktop.context.fileError.open-resource': '请先保存并关闭此文件或文件夹内已打开的文件,再重试', + 'desktop.context.fileError.result-unknown': '操作结果暂不确定,已尝试刷新目录;请核对目录和系统回收站后再操作', + 'desktop.context.fileError.failed': '操作失败,请刷新目录并检查权限或文件占用', 'desktop.context.wrap': '自动换行', 'desktop.context.refreshFiles': '刷新文件目录', 'desktop.context.retry': '重试', diff --git a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue index 0788b279..ee170788 100644 --- a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue +++ b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue @@ -6,6 +6,7 @@ import type { ResourceRef, WorkbenchView } from '@/workbench/common/workbench' import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' import { NButton } from 'naive-ui' import { computed, onScopeDispose, shallowRef, useTemplateRef, watch } from 'vue' +import { useBuddyI18n } from '@/i18n/buddyI18n' import WorkbenchMenu from '@/shared/ui/contributions/WorkbenchMenu.vue' import DesktopDocumentContent from '@/shared/ui/files/DesktopDocumentContent.vue' import DesktopDocumentToolbar from '@/shared/ui/files/DesktopDocumentToolbar.vue' @@ -15,6 +16,7 @@ import { useWorkbench } from '@/workbench/browser/workbenchContext' const props = withDefaults(defineProps<{ view: WorkbenchView, models: TextModelPool, language: 'zh-CN' | 'en-US', writeClipboardText: (text: string) => Promise, toolbarTarget?: HTMLElement | null, visible?: boolean }>(), { visible: true }) const { copies, controller, labels } = useWorkbench() +const { t } = useBuddyI18n(() => props.language) const container = useTemplateRef('container') const failed = shallowRef(false) const copy = shallowRef(copies.get(props.view.resource)) @@ -89,8 +91,8 @@ watch([container, attempt, viewId, identity], async ([element, , viewId, key], _ release = lease.release ownedEditor = lease.monaco.editor.create(element, { model: lease.model, - readOnly: mode.value !== 'edit', - domReadOnly: mode.value !== 'edit', + readOnly: mode.value !== 'edit' || !!copy.value?.blocked, + domReadOnly: mode.value !== 'edit' || !!copy.value?.blocked, automaticLayout: true, fontSize: 13, lineHeight: 21, @@ -120,7 +122,7 @@ watch([container, attempt, viewId, identity], async ([element, , viewId, key], _ failed.value = true } }, { immediate: true }) -watch(mode, value => editor?.updateOptions({ readOnly: value !== 'edit', domReadOnly: value !== 'edit' })) +watch([mode, () => copy.value?.blocked], ([value, blocked]) => editor?.updateOptions({ readOnly: value !== 'edit' || !!blocked, domReadOnly: value !== 'edit' || !!blocked }), { flush: 'sync' }) watch(() => props.view.state.wrap, value => editor?.updateOptions({ wordWrap: (value ?? controller.configuration.get('workbench.wordWrap')) ? 'on' : 'off' })) async function retry() { const key = identity.value @@ -141,11 +143,14 @@ onScopeDispose(controller.configuration.subscribe(() => editor?.updateOptions({