Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .playwright/scripts/__tests__/file-explorer.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'node:path'
import { expect, test } from '../fixtures/electron.mjs'

async function createSpace(app, page, directory) {
await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { featureAnnouncementsEnabled: false } }))
await expect(page.getByTestId('feature-discovery-reminder')).toHaveCount(0)
await app.evaluate(({ dialog }, directory) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] })
}, directory)
Expand Down Expand Up @@ -137,6 +139,43 @@ test('file explorer preserves edits and undo through rename, rejects copy confli
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

test('file editor distinguishes external deletion and recovers without losing unsaved edits', async ({ buddy }, testInfo) => {
const instance = await buddy.createInstance('file-missing-recovery')
const directory = path.join(instance.home, 'documents')
const source = path.join(directory, 'README.md')
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(source, 'Original document.\n')
const { app, page, diagnostics } = await instance.launch()
await createSpace(app, page, directory)
await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { contextPanelMode: 'independent' } }))
const toggle = page.getByTestId('context-panel-toggle')
if (await toggle.getAttribute('aria-expanded') === 'false')
await toggle.click()
await page.getByTestId('task-context-open-files').click()
await page.getByTestId('file-space-picker').getByRole('button', { name: /文件树验收/ }).click()
await page.getByTestId('context-file-tree').locator('[data-path="README.md"]').click()
await page.getByTestId('document-mode-edit').click()
const editor = page.getByTestId('workbench-text-editor')
await expect(editor.locator('.view-lines')).toContainText('Original document.')
await editor.locator('.view-lines').click()
await page.keyboard.press('Control+End')
await page.keyboard.insertText('Keep this draft.')
await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true')
await fs.rm(source)
await expect(page.locator('.file-editor__error')).toContainText('文件已被移动或删除')
await expect(editor.locator('.view-lines')).toContainText('Keep this draft.')
await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true')
await page.screenshot({ path: testInfo.outputPath('file-missing.png') })
await fs.writeFile(source, 'Restored outside.\n')
await expect(page.locator('.file-editor__error')).toHaveCount(0)
await expect(page.locator('.file-editor__conflict')).toContainText('Restored outside.')
await expect(editor.locator('.view-lines')).toContainText('Keep this draft.')
await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true')
expect(await fs.readFile(source, 'utf8')).toBe('Restored outside.\n')
await page.screenshot({ path: testInfo.outputPath('file-restored.png') })
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

test('copy path uses native clipboard text independently from file copy and cut', async ({ buddy }, testInfo) => {
const instance = await buddy.createInstance('file-copy-path')
const directory = path.join(instance.home, process.platform === 'win32' ? 'documents' : 'documents\\draft')
Expand Down
165 changes: 160 additions & 5 deletions apps/buddy/native/host/__tests__/file_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,152 @@ fn id() -> String {
uuid::Uuid::new_v4().to_string()
}

#[test]
fn read_reports_missing_targets_and_reads_restored_files() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().join("root");
std::fs::create_dir(&root).unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);

for path in ["note.md", "missing/note.md"] {
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":path}))["error"],
"FILE_NOT_FOUND"
);
}
assert_eq!(
engine.call(json!({"type":"list","root":"root","path":"missing"}))["error"],
"FILE_IO_FAILED"
);

std::fs::write(root.join("note.md"), "original").unwrap();
let original = engine.call(json!({"type":"read","root":"root","path":"note.md"}));
assert_eq!(original["result"]["text"], "original");
std::fs::remove_file(root.join("note.md")).unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"],
"FILE_NOT_FOUND"
);
std::fs::write(root.join("note.md"), "restored").unwrap();
let restored = engine.call(json!({"type":"read","root":"root","path":"note.md"}));
assert_eq!(restored["result"]["text"], "restored");
assert_ne!(restored["result"]["etag"], original["result"]["etag"]);
}

#[test]
fn read_reports_missing_target_when_parent_becomes_a_file() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().join("root");
let parent = root.join("nested");
std::fs::create_dir_all(&parent).unwrap();
std::fs::write(parent.join("note.md"), "original").unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["result"]["text"],
"original"
);

std::fs::remove_file(parent.join("note.md")).unwrap();
std::fs::remove_dir(&parent).unwrap();
std::fs::write(&parent, "not a directory").unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["error"],
"FILE_NOT_FOUND"
);
assert_eq!(
engine.call(json!({"type":"acquire","leaseId":id(),"external":false,"claims":[{"root":"root","path":"nested/note.md","subtree":false}]}))["error"],
"FILE_IO_FAILED"
);

std::fs::remove_file(&parent).unwrap();
std::fs::create_dir(&parent).unwrap();
std::fs::write(parent.join("note.md"), "restored").unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["result"]["text"],
"restored"
);
}

#[test]
fn read_rejects_unknown_and_revoked_roots() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().join("root");
std::fs::create_dir(&root).unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);
engine.call(json!({"type":"revoke","key":"root"}));
for key in ["root", "unknown"] {
assert_eq!(
engine.call(json!({"type":"read","root":key,"path":"missing.md"}))["error"],
"FILE_PERMISSION_REVOKED"
);
}
}

#[cfg(unix)]
#[test]
fn read_does_not_classify_unavailable_or_replaced_roots_as_missing_targets() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().join("root");
let offline = directory.path().join("offline");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("note.md"), "retained").unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);

std::fs::rename(&root, &offline).unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"],
"FILE_IO_FAILED"
);
std::fs::write(&root, "not a root directory").unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"],
"FILE_IO_FAILED"
);
std::fs::remove_file(&root).unwrap();
std::fs::rename(&offline, &root).unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"note.md"}))["result"]["text"],
"retained"
);

std::fs::rename(&root, &offline).unwrap();
std::fs::create_dir(&root).unwrap();
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"missing.md"}))["error"],
"FILE_PERMISSION_REVOKED"
);
}

#[cfg(unix)]
#[test]
fn read_preserves_permission_errors_for_files_and_inaccessible_parents() {
use std::os::unix::fs::PermissionsExt;

let directory = tempfile::tempdir().unwrap();
let root = directory.path().join("root");
std::fs::create_dir_all(root.join("private")).unwrap();
std::fs::write(root.join("note.md"), "private").unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);

for (restricted, target) in [("note.md", "note.md"), ("private", "private/missing.md")] {
let path = root.join(restricted);
let permissions = std::fs::metadata(&path).unwrap().permissions();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0)).unwrap();
let response = engine.call(json!({"type":"read","root":"root","path":target}));
std::fs::set_permissions(&path, permissions).unwrap();
assert_eq!(response["error"], "FILE_IO_FAILED");
}
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"note.md"}))["result"]["text"],
"private"
);
}

#[test]
fn journal_deduplicates_mutations_and_never_overwrites_a_destination() {
let directory = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -304,11 +450,20 @@ fn capabilities_reject_symlink_escape_and_revoked_roots() {
std::os::unix::fs::symlink(root.join("inside"), root.join("alias")).unwrap();
let mut engine = Engine::new(&directory.path().join("journal"));
engine.register("root", &root);
assert!(
engine
.call(json!({"type":"read","root":"root","path":"alias/local"}))
.get("error")
.is_some()
for path in [
"alias/local",
"alias/missing",
"escape/outside",
"escape/missing",
] {
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":path}))["error"],
"FILE_UNSUPPORTED"
);
}
assert_eq!(
engine.call(json!({"type":"read","root":"root","path":"../missing"}))["error"],
"FILE_INVALID_REQUEST"
);
assert!(
engine
Expand Down
30 changes: 26 additions & 4 deletions apps/buddy/native/host/src/file_engine/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ pub fn digest(bytes: &[u8]) -> String {
}

pub fn read(root: &Root, path: &str) -> Result<Value> {
root.resolve(path)?;
root.resolve(path)
.map_err(|error| read_target_error(root, error))?;
let path = relative(path)?;
root.check()?;
let file = open_regular(&root.dir, &path)?;
let file = open_read(&root.dir, &path).map_err(|error| read_target_error(root, error))?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() > MAX_TEXT as u64 {
return Err(Error::Unsupported);
Expand All @@ -38,6 +39,23 @@ pub fn read(root: &Root, path: &str) -> Result<Value> {
Ok(json!({ "text": text, "etag": etag, "identity": identity(&metadata)? }))
}

fn read_target_error(root: &Root, error: Error) -> Error {
match error {
Error::Io(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
match root.check() {
Ok(()) => Error::NotFound,
Err(error) => error,
}
}
error => error,
}
}

pub fn list(root: &Root, path: &str, cursor: Option<&str>) -> Result<Value> {
root.resolve(path)?;
root.check()?;
Expand Down Expand Up @@ -284,7 +302,7 @@ pub fn copy_entry(
Ok(())
}

fn open_regular(dir: &Dir, path: &Path) -> Result<cap_std::fs::File> {
fn open_read(dir: &Dir, path: &Path) -> Result<cap_std::fs::File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
Expand All @@ -294,7 +312,11 @@ fn open_regular(dir: &Dir, path: &Path) -> Result<cap_std::fs::File> {
(rustix::fs::OFlags::NONBLOCK | rustix::fs::OFlags::NOFOLLOW).bits() as i32,
);
}
let file = dir.open_with(path, &options)?;
Ok(dir.open_with(path, &options)?)
}

fn open_regular(dir: &Dir, path: &Path) -> Result<cap_std::fs::File> {
let file = open_read(dir, path)?;
if !file.metadata()?.is_file() {
return Err(Error::Unsupported);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/native/host/src/file_engine/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ pub enum Error {
IdConflict,
#[error("FILE_UNSUPPORTED")]
Unsupported,
#[error("FILE_NOT_FOUND")]
NotFound,
#[error(transparent)]
NotApplied(Box<Error>),
#[error("FILE_IO_FAILED")]
Expand Down
4 changes: 3 additions & 1 deletion apps/buddy/service/src/artifacts/ArtifactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ export class ArtifactService {
) {
throw new ArtifactError('VALIDATION_FAILED')
}
const bytes = await readFile(artifact.currentPath)
const bytes = await readFile(artifact.currentPath).catch((error: NodeJS.ErrnoException) => {
throw error.code === 'ENOENT' || error.code === 'ENOTDIR' ? new ArtifactError('FILE_NOT_FOUND') : error
})
if (bytes.byteLength > BUDDY_ARTIFACT_TEXT_BYTES_LIMIT)
throw new ArtifactError('ARTIFACT_SIZE_LIMIT')
let text: string
Expand Down
21 changes: 21 additions & 0 deletions apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ describe('artifactService', () => {
expect(fixture.service.listConversationArtifacts('conversation-1')).toHaveLength(2)
})

it('reports an output whose file was moved or deleted as not found', async () => {
const fixture = await createFixture()
const path = join(fixture.workspace, 'report.md')
await writeFile(path, '# report\n')
const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] })
await rm(path)
await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
})

it('reports an output as missing when its parent has become a regular file', async () => {
const fixture = await createFixture()
const directory = join(fixture.workspace, 'reports')
const path = join(directory, 'report.md')
await mkdir(directory)
await writeFile(path, '# report\n')
const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] })
await rm(directory, { recursive: true })
await writeFile(directory, 'not a directory')
await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
})

it.each(['report.md', 'REPORT.MD', 'report.markdown'])('reads %s as Markdown without changing the file', async (name) => {
const fixture = await createFixture()
const path = join(fixture.workspace, name)
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/service/src/rpc/runtimeRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type BuddyServiceErrorCode
| 'DIRECTORY_NOT_AUTHORIZED'
| 'MODEL_INPUT_UNSUPPORTED'
| 'MODEL_INPUT_TOO_LARGE'
| 'FILE_NOT_FOUND'
| 'SPACE_UNAVAILABLE'
| 'VALIDATION_FAILED'

Expand Down
Loading
Loading