From 0458424f9c5d28d3682b5e51cc0f01ecad5cd005 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:32:33 +0800 Subject: [PATCH] fix(project): expire stale file clipboard after OS replacement (#598) Project Paste could reuse an earlier file copy after another app replaced the OS clipboard, and Cut files stayed dimmed. The tree now tracks the hand-off to the OS clipboard and rechecks it on window focus and just before Paste. A local Copy or Cut is cleared once the OS holds a different or empty file list; failed writes and clipboard read errors keep it. The native adapters now distinguish read failures from empty lists. In remote windows, Copy and Cut write newline-joined server paths as plain text, tree Paste keeps the server source, and a later OS file list replaces the remote copy and imports those client files. A plain-text replacement stays undetectable there without a clipboard read that may prompt. Verified on macOS, locally and against a remote host. The Windows clipboard code was not run. --- crates/ide-clipboard/src/lib.rs | 48 ++-- src-tauri/src/commands/clipboard.rs | 2 +- .../project-tree/ProjectView.test.tsx | 38 ++- src/features/project-tree/ProjectView.tsx | 3 +- src/features/project-tree/clipboard.ts | 9 +- src/features/project-tree/operations.test.ts | 225 +++++++++++++++++- src/features/project-tree/operations.ts | 42 +++- src/features/project-tree/store.ts | 4 + 8 files changed, 327 insertions(+), 44 deletions(-) diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 05bc3590..a682260c 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -22,7 +22,8 @@ use std::path::PathBuf; const HEADER: usize = 20; /// The absolute paths on the clipboard, empty when it holds no file list. -pub fn files() -> Vec { +/// Read failures remain errors so callers can retain an internal copy. +pub fn files() -> std::io::Result> { imp::files() } @@ -31,9 +32,8 @@ pub fn has_files() -> bool { imp::has_files() } -/// Replaces the clipboard with `paths`. False when it is busy or holds nothing -/// to write; the clipboard is a shared resource, so callers treat this as -/// best-effort. +/// Replaces the clipboard with `paths`. True only when the file list was +/// accepted; writing just the accompanying text is not a successful hand-off. pub fn set_files(paths: &[PathBuf]) -> bool { if paths.is_empty() { return false; @@ -160,25 +160,29 @@ mod imp { (unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0 } - pub fn files() -> Vec { - if !has_files() || !open() { - return Vec::new(); + pub fn files() -> std::io::Result> { + if !open() { + return Err(std::io::Error::last_os_error()); + } + if !has_files() { + unsafe { CloseClipboard() }; + return Ok(Vec::new()); } let memory = unsafe { GetClipboardData(CF_HDROP) }; let paths = if memory.is_null() { - Vec::new() + Err(std::io::Error::last_os_error()) } else { // SAFETY: the clipboard keeps the block alive while it is open, and // `GlobalSize` bounds the bytes read from it. unsafe { let data = GlobalLock(memory); if data.is_null() { - Vec::new() + Err(std::io::Error::last_os_error()) } else { let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)); let paths = decode(bytes); GlobalUnlock(memory); - paths + Ok(paths) } } }; @@ -241,7 +245,7 @@ mod imp { GlobalFree(memory); return false; } - let mut taken = !SetClipboardData(CF_HDROP, memory).is_null(); + let taken = !SetClipboardData(CF_HDROP, memory).is_null(); if !taken { GlobalFree(memory); } @@ -249,7 +253,6 @@ mod imp { if !text_memory.is_null() { // The text alone still pastes somewhere useful. let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null(); - taken |= text_taken; if !text_taken { GlobalFree(text_memory); } @@ -296,19 +299,23 @@ mod imp { }) } - pub fn files() -> Vec { + pub fn files() -> std::io::Result> { read_file_urls(|board, classes, options| { // SAFETY: as in `has_files`; every object read for `NSURL` is one. let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else { - return Vec::new(); + return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } { + Err(std::io::Error::other("Cannot read clipboard files")) + } else { + Ok(Vec::new()) + }; }; - objects + Ok(objects .iter() .filter_map(|object| object.downcast::().ok()) .filter_map(|url| url.to_file_path()) // Finder ends a folder's URL in `/`; the project view names it without. .map(|path| path.components().collect()) - .collect() + .collect()) }) } @@ -329,11 +336,12 @@ mod imp { // the paths' own text is set back with it, so an editor paste // inserts them (IDEA writes the list and the text together). board.clearContents(); - board.writeObjects(&NSArray::from_slice(&writers)); + let written = board.writeObjects(&NSArray::from_slice(&writers)); let text = NSString::from_str( &paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\n"), ); - board.setString_forType(&text, unsafe { NSPasteboardTypeString }) + board.setString_forType(&text, unsafe { NSPasteboardTypeString }); + written }) } } @@ -346,8 +354,8 @@ mod imp { false } - pub fn files() -> Vec { - Vec::new() + pub fn files() -> std::io::Result> { + Ok(Vec::new()) } pub fn set_files(_paths: &[PathBuf]) -> bool { diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs index 0cc2ce6f..813ac40b 100644 --- a/src-tauri/src/commands/clipboard.rs +++ b/src-tauri/src/commands/clipboard.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; #[tauri::command] #[specta::specta] pub async fn clipboard_file_list() -> IdeResult> { - blocking(|| Ok(ide_clipboard::files().iter().map(|p| slash(p)).collect())).await + blocking(|| Ok(ide_clipboard::files()?.iter().map(|p| slash(p)).collect())).await } /// Whether the clipboard holds files: cheap, for the Paste item's enabled state. diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 95ef18ba..05ce9b99 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -10,7 +10,7 @@ import { MessagesHost } from "@/workbench/messages"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; import { setRefactorBackendForTests } from "./fileRefactor"; import { ProjectView } from "./ProjectView"; -import { beginNew } from "./operations"; +import { beginNew, canPaste } from "./operations"; import { initProjectTree, setViewOption, useProjectTree } from "./store"; import type { OutlineItem } from "@/bindings"; import { _emitEditorEvent } from "@/editor/api"; @@ -110,7 +110,7 @@ describe("ProjectView", () => { setOsClipboardForTests({ files: async () => ["/elsewhere/Photo.png"], hasFiles: async () => true, - setFiles: async () => {}, + setFiles: async () => false, }); try { const { tree, row, rows } = setup(); @@ -127,6 +127,40 @@ describe("ProjectView", () => { } }); + it("clears cut dimming and disables Paste on focus after the OS clipboard becomes text", async () => { + let files: string[] = []; + setOsClipboardForTests({ + files: async () => files, + hasFiles: async () => files.length > 0, + setFiles: async (paths) => { + files = paths; + return true; + }, + }); + try { + const { tree, row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + fireEvent.mouseDown(row("README.md")); + tree.focus(); + fireEvent.keyDown(tree, { key: "x", code: "KeyX", metaKey: true }); + const content = row("README.md").querySelector("[data-project-path]")!; + expect(content.className).toMatch(/cut/); + await act(async () => { + await useProjectTree.getState().clipboard?.osWrite; + }); + files = []; + fireEvent.focus(window); + await waitFor(() => expect(content.className).not.toMatch(/cut/)); + expect(useProjectTree.getState().clipboard).toBeNull(); + expect(canPaste()).toBe(false); + fireEvent.keyDown(tree, { key: "v", code: "KeyV", metaKey: true }); + expect(screen.queryByRole("dialog", { name: "Copy" })).toBeNull(); + expect(screen.queryByRole("dialog", { name: "Move" })).toBeNull(); + } finally { + setOsClipboardForTests(null); + } + }); + it("opens files on double click and previews on single click only when enabled", async () => { const { row, rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index d5ac7a9c..832cbb51 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -654,8 +654,7 @@ export function ProjectView() { }; }, []); - // Files copied in Explorer keep Paste usable: the OS clipboard can change - // while this window is in the background, so re-ask whenever it comes back. + // Reconcile external files and expired Copy/Cut state when returning from another app. useEffect(() => { void refreshOsClipboard(); const refresh = () => void refreshOsClipboard(); diff --git a/src/features/project-tree/clipboard.ts b/src/features/project-tree/clipboard.ts index 45970070..160981b1 100644 --- a/src/features/project-tree/clipboard.ts +++ b/src/features/project-tree/clipboard.ts @@ -11,8 +11,8 @@ export interface OsClipboard { files(): Promise; /** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */ hasFiles(): Promise; - /** Puts `paths` there, so Explorer can paste them. */ - setFiles(paths: string[]): Promise; + /** True only when the OS accepted the file list; false for unsupported hosts. */ + setFiles(paths: string[]): Promise; } const native: OsClipboard = { @@ -31,11 +31,12 @@ const native: OsClipboard = { }, async setFiles(paths) { - if (!isTauri() || !paths.length) return; + if (!isTauri() || !paths.length) return false; try { - await call(commands.clipboardSetFileList(paths)); + return await call(commands.clipboardSetFileList(paths)); } catch { // The internal clipboard still works; a failed hand-off is not an error. + return false; } }, }; diff --git a/src/features/project-tree/operations.test.ts b/src/features/project-tree/operations.test.ts index 88311517..de1d9ea0 100644 --- a/src/features/project-tree/operations.test.ts +++ b/src/features/project-tree/operations.test.ts @@ -1,12 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MEMBER_PREFIX, type ProjectNode } from "./model"; -import { importExternal, paste, selectionAfterDelete, transfer } from "./operations"; +import { + canPaste, + copySelection, + importExternal, + paste, + refreshOsClipboard, + selectionAfterDelete, + transfer, +} from "./operations"; import { IpcError } from "@/platform/ipc"; -import { pendingFileRefactorDialog } from "./FileRefactorDialogs"; +import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs"; import { useRemote } from "@/platform/remote"; import { provideNotificationService } from "@/workbench/contributions"; import { MemoryFs, setProjectFsForTests } from "./fs"; -import { initProjectTree, loadDir, setClipboard, setSelection } from "./store"; +import { initProjectTree, loadDir, setClipboard, setSelection, useProjectTree } from "./store"; import { setOsClipboardForTests } from "./clipboard"; let fs: MemoryFs; @@ -62,7 +70,7 @@ describe("OS file imports", () => { it("does not paste into the project root when a member row is selected", async () => { const files = vi.fn(async () => ["/outside/local.txt"]); - setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => {} }); + setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => false }); const imported = vi.spyOn(fs, "importLocal"); const key = `${MEMBER_PREFIX}/p/local.txt\0function:main`; setSelection(new Set([key]), key); @@ -78,7 +86,7 @@ describe("OS file imports", () => { setOsClipboardForTests({ files: async () => ["/p/local.txt"], hasFiles: async () => true, - setFiles: async () => {}, + setFiles: async () => false, }); const imported = vi.spyOn(fs, "importLocal"); for (let i = 0; i < 2; i++) { @@ -109,7 +117,7 @@ describe("OS file imports", () => { throw new Error("clipboard unavailable"); }, hasFiles: async () => true, - setFiles: async () => {}, + setFiles: async () => false, }); await paste("/p/target"); expect(notify).toHaveBeenCalledWith( @@ -123,7 +131,7 @@ describe("OS file imports", () => { throw new Error("busy"); }, hasFiles: async () => false, - setFiles: async () => {}, + setFiles: async () => false, }); setClipboard({ paths: ["/p/local.txt"], cut }); await paste("/p/target"); @@ -156,3 +164,206 @@ describe("OS file imports", () => { expect(imported).not.toHaveBeenCalled(); }); }); + +describe("project clipboard ownership", () => { + beforeEach(async () => { + fs = new MemoryFs({ "/p/target": null, "/p/source.txt": "source", "/other.txt": "other" }, "/p"); + setProjectFsForTests(fs, "/p"); + initProjectTree("/p", "p"); + await loadDir("/p"); + setSelection(new Set(["/p/source.txt"]), "/p/source.txt"); + closeFileRefactorDialog(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + useRemote.setState({ session: null }); + setProjectFsForTests(null); + setOsClipboardForTests(null); + closeFileRefactorDialog(); + }); + + const cases = [false, true].flatMap((handedOff) => + ["same", "other", "empty", "error"].flatMap((osList) => + [false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))), + ), + ); + + it.each(cases)("hand-off=$handedOff OS=$osList cut=$cut on $entry", async ({ handedOff, osList, cut, entry }) => { + const files = vi.fn(async () => { + if (osList === "error") throw new Error("busy"); + return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : []; + }); + const setFiles = vi.fn(async () => handedOff); + setOsClipboardForTests({ files, hasFiles: async () => osList === "same" || osList === "other", setFiles }); + copySelection(cut); + const clipboard = useProjectTree.getState().clipboard; + expect(clipboard).toEqual(expect.objectContaining({ paths: ["/p/source.txt"], cut })); + expect(setFiles).toHaveBeenCalledWith(["/p/source.txt"]); + const stale = handedOff && (osList === "other" || osList === "empty"); + if (entry === "focus") await refreshOsClipboard(); + else await paste("/p/target"); + expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard); + expect(canPaste()).toBe(!(handedOff && osList === "empty")); + // canPaste only consults cached state, even when called repeatedly by menus. + canPaste(); + expect(files).toHaveBeenCalledTimes(1); + if (entry === "paste") { + if (osList === "other") { + expect(fs.nodes.has("/p/target/other.txt")).toBe(true); + expect(fs.nodes.has("/p/target/source.txt")).toBe(false); + expect(pendingFileRefactorDialog()).toBeNull(); + } else if (stale) { + expect(pendingFileRefactorDialog()).toBeNull(); + expect(fs.nodes.has("/p/target/source.txt")).toBe(false); + } else { + expect(pendingFileRefactorDialog()).toEqual( + expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] }), + ); + } + } + }); + + it.each([false, true])("keeps the internal clipboard after a rejected hand-off (cut=%s)", async (cut) => { + setOsClipboardForTests({ + files: async () => [], + hasFiles: async () => false, + setFiles: async () => { + throw new Error("unavailable"); + }, + }); + copySelection(cut); + await refreshOsClipboard(); + await paste("/p/target"); + expect(pendingFileRefactorDialog()?.kind).toBe(cut ? "move" : "copy"); + }); + + const remoteCases = [false, true].flatMap((handedOff) => + ["same", "other", "empty", "error"].flatMap((osList) => + [false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))), + ), + ); + + it.each(remoteCases)( + "remote hand-off=$handedOff OS=$osList cut=$cut on $entry", + async ({ handedOff, osList, cut, entry }) => { + useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } }); + const writeText = vi.fn(async () => { + if (!handedOff) throw new Error("unavailable"); + }); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const setFiles = vi.fn(async () => true); + const files = vi.fn(async () => { + if (osList === "error") throw new Error("busy"); + return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : []; + }); + setOsClipboardForTests({ files, hasFiles: async () => false, setFiles }); + copySelection(cut); + const clipboard = useProjectTree.getState().clipboard; + const stale = handedOff && (osList === "same" || osList === "other"); + const copied = vi.spyOn(fs, "copy"); + const imported = vi.spyOn(fs, "importLocal").mockResolvedValue([]); + if (entry === "focus") await refreshOsClipboard(); + else await paste("/p/target"); + expect(writeText).toHaveBeenCalledWith("/p/source.txt"); + expect(setFiles).not.toHaveBeenCalled(); + expect(files).toHaveBeenCalledTimes(1); + expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard); + expect(canPaste()).toBe(true); + if (entry === "paste") { + if (stale) { + expect(imported).toHaveBeenCalledWith( + osList === "same" ? "/p/source.txt" : "/other.txt", + osList === "same" ? "/p/target/source.txt" : "/p/target/other.txt", + expect.anything(), + ); + expect(pendingFileRefactorDialog()).toBeNull(); + expect(copied).not.toHaveBeenCalled(); + } else { + const dialog = pendingFileRefactorDialog()!; + expect(dialog).toEqual(expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] })); + if (dialog.kind === "copy") { + await dialog.copy("/p/target", null, false); + expect(copied).toHaveBeenCalledWith("/p/source.txt", "/p/target/source.txt"); + } + expect(imported).not.toHaveBeenCalled(); + } + } + }, + ); + + it("writes multiple remote paths as newline-joined text", async () => { + useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } }); + await fs.createFile("/p/second.txt"); + await loadDir("/p", true); + setSelection(new Set(["/p/source.txt", "/p/second.txt"]), "/p/source.txt"); + const writeText = vi.fn(async () => {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + copySelection(false); + expect(await useProjectTree.getState().clipboard?.osWrite).toBe(true); + expect(writeText).toHaveBeenCalledWith("/p/source.txt\n/p/second.txt"); + }); + + it("waits for a pending remote text write before reading file URLs", async () => { + useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } }); + let finish!: () => void; + vi.stubGlobal("navigator", { + clipboard: { + writeText: () => + new Promise((resolve) => { + finish = resolve; + }), + }, + }); + const files = vi.fn(async () => []); + setOsClipboardForTests({ files, hasFiles: async () => false, setFiles: async () => true }); + copySelection(false); + const refreshing = refreshOsClipboard(); + await Promise.resolve(); + expect(files).not.toHaveBeenCalled(); + finish(); + await refreshing; + expect(useProjectTree.getState().clipboard?.remote).toBe(true); + }); + + it("waits for the hand-off before reading the OS clipboard", async () => { + let finish!: (written: boolean) => void; + const files = vi.fn(async () => ["/p/source.txt"]); + setOsClipboardForTests({ + files, + hasFiles: async () => false, + setFiles: () => + new Promise((r) => { + finish = r; + }), + }); + copySelection(true); + const refreshing = refreshOsClipboard(); + await Promise.resolve(); + expect(files).not.toHaveBeenCalled(); + finish(true); + await refreshing; + expect(useProjectTree.getState().clipboard?.cut).toBe(true); + }); + + it("ignores a stale read that completes after a newer Copy", async () => { + let finish!: (paths: string[]) => void; + setOsClipboardForTests({ + files: () => + new Promise((r) => { + finish = r; + }), + hasFiles: async () => true, + setFiles: async () => true, + }); + copySelection(true); + const refreshing = refreshOsClipboard(); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + copySelection(false); + const newer = useProjectTree.getState().clipboard; + finish([]); + await refreshing; + expect(useProjectTree.getState().clipboard).toBe(newer); + expect(canPaste()).toBe(true); + }); +}); diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 595b3d32..0f4711f6 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -479,9 +479,14 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) { export function copySelection(cut: boolean) { const paths = topLevelPaths(selectedNodes().filter((n) => (cut ? mutable(n) : !n.root && !n.group))); if (!paths.length) return; - setClipboard({ paths, cut }); - // Hand the files to the OS too, so Explorer can paste them (as IDEA does). - void osClipboard().setFiles(paths); + // Remote paths are useful as text, but do not name files on the client. + const remote = isRemoteWindow(); + const osWrite = (async () => { + if (!remote) return osClipboard().setFiles(paths); + await navigator.clipboard.writeText(paths.join("\n")); + return true; + })().catch(() => false); + setClipboard({ paths, cut, remote, osWrite }); } /** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */ @@ -489,7 +494,25 @@ let osHasFiles = false; /** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */ export async function refreshOsClipboard() { - osHasFiles = await osClipboard().hasFiles(); + try { + await readOsClipboard(); + } catch { + // A busy clipboard is not evidence that the user's copy has changed. + } +} + +async function readOsClipboard(): Promise { + const { clipboard } = get(); + const handedOff = await clipboard?.osWrite; + const external = await osClipboard().files(); + // A newer Copy/Cut owns the state if this read (or its hand-off) was delayed. + if (get().clipboard !== clipboard) return null; + osHasFiles = external.length > 0; + // Our remote text write removes file URLs, so any files were copied later. + // A failed write leaves ownership uncertain; retain the remote copy then. + if (handedOff && (clipboard?.remote ? external.length > 0 : !sameFiles(external, clipboard?.paths))) + setClipboard(null); + return external; } export function canPaste(): boolean { @@ -501,20 +524,23 @@ export function canPaste(): boolean { * dialog (IDEA). Files copied in Explorer paste as copies. */ export async function paste(target = targetFolder()) { - const { clipboard } = get(); if (!target || isReadOnlyPath(target)) return; let external: string[] = []; try { - external = await osClipboard().files(); + const files = await readOsClipboard(); + if (files === null) return; + external = files; } catch (e) { - if (!clipboard?.paths.length) { + if (!get().clipboard?.paths.length) { fail("Cannot access the clipboard", e); return; } } + const { clipboard } = get(); // The last copy is the one the user expects to paste, so another app's files // win over ours — unless they are the copy we handed to the OS ourselves. - if (external.length && !sameFiles(external, clipboard?.paths)) return pasteFromOsClipboard(external, target); + if (!clipboard?.remote && external.length && !sameFiles(external, clipboard?.paths)) + return pasteFromOsClipboard(external, target); if (!clipboard?.paths.length) { notifications().notify({ level: "info", title: "No files on the clipboard" }); return; diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index a4c9ac53..3d90bfcb 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -87,6 +87,10 @@ export type Editing = export interface Clipboard { paths: string[]; cut: boolean; + /** Server paths cannot be represented by the client's OS file list. */ + remote?: boolean; + /** Pending OS hand-off, so a read cannot invalidate this copy before its write finishes. */ + osWrite?: Promise; } interface ProjectTreeState {