Skip to content

Commit e35f0f6

Browse files
committed
feat(desktop): file-tree context-menu ops, git timeline, system tray
- file-tree right-click menu: copy/cut/paste/delete/rename/archive/extract - git file timeline dialog backed by a local git binary - system tray with close-to-tray (graceful Linux GNOME fallback when no StatusNotifierItem host, so a window is never stranded) - powerSaveBlocker to prevent idle sleep while the app is active - multi-platform hardening: EXDEV cross-device move fallback, Windows illegal filename chars, git-missing degradation, loopback-only filesystem bridge so a remote Server Edition sidecar never touches local paths - CI: add macOS to the unit matrix; wire desktop test/test:ci into turbo - tests: 62 unit cases covering pure decision logic and real fs/git/zip paths
1 parent c2a8f17 commit e35f0f6

27 files changed

Lines changed: 1765 additions & 46 deletions

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
host: blacksmith-4vcpu-ubuntu-2404
3232
- name: windows
3333
host: blacksmith-4vcpu-windows-2025
34+
- name: macos
35+
host: macos-14
3436
runs-on: ${{ matrix.settings.host }}
3537
defaults:
3638
run:

packages/app/src/app.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { CommandProvider } from "@/context/command"
3131
import { CommentsProvider } from "@/context/comments"
3232
import { DebugProvider } from "@/context/debug"
3333
import { FileProvider } from "@/context/file"
34+
import type { DesktopApi } from "@/utils/desktop-api"
3435
import { GatewayProvider } from "@/context/gateway"
3536
import { ServerSDKProvider } from "@/context/server-sdk"
3637
import { ServerSyncProvider } from "@/context/server-sync"
@@ -76,10 +77,7 @@ declare global {
7677
__DEEPAGENT_CODE__?: {
7778
deepLinks?: string[]
7879
}
79-
api?: {
80-
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
81-
exportDebugLogs?: (options?: { windowMs?: number; pick?: boolean }) => Promise<string | null>
82-
}
80+
api?: DesktopApi
8381
}
8482
}
8583

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { createSignal, Show } from "solid-js"
2+
import { ContextMenu } from "@deepagent-code/ui/context-menu"
3+
import type { FileNode } from "@deepagent-code/sdk/v2"
4+
import { useFile } from "@/context/file"
5+
import { useLanguage } from "@/context/language"
6+
import { showToast } from "@/utils/toast"
7+
import { desktopApi, isLocalFilesystemOp } from "@/utils/desktop-api"
8+
import { canExtract, canOpenTimeline, canPaste, parentPath } from "./file-tree-menu"
9+
10+
// Process-local clipboard for copy/cut → paste between file-tree nodes. Lives for the renderer
11+
// lifetime; cut entries are cleared after a successful paste.
12+
type Clip = { mode: "copy" | "cut"; absolute: string }
13+
const [clip, setClip] = createSignal<Clip | null>(null)
14+
15+
export function FileTreeMenuContent(props: {
16+
node: FileNode
17+
onRename: () => void
18+
onOpenTimeline: (node: FileNode) => void
19+
}) {
20+
const file = useFile()
21+
const language = useLanguage()
22+
23+
// File-ops and the git timeline run in the desktop main process against the local filesystem.
24+
// They are only available on the desktop build AND when the connected sidecar is local
25+
// (loopback). On the web build or against a remote Server Edition, these menu items degrade to
26+
// disabled so the user never triggers an operation that would fail or touch the wrong host.
27+
const localFs = () => isLocalFilesystemOp({ desktop: Boolean(desktopApi()), localSidecar: file.isLocalSidecar() })
28+
const root = () => file.directory()
29+
30+
const refresh = () => {
31+
void file.tree.refresh(parentPath(props.node.path))
32+
if (props.node.type === "directory") void file.tree.refresh(props.node.path)
33+
}
34+
35+
const report = (
36+
res: { ok: boolean; error?: string } | undefined,
37+
okKey: string,
38+
errKey: string,
39+
) => {
40+
if (!res) return
41+
if (res.ok) {
42+
showToast({ variant: "success", title: language.t(okKey) })
43+
refresh()
44+
return
45+
}
46+
showToast({ variant: "error", title: language.t(errKey), description: res.error })
47+
}
48+
49+
const copyText = (text: string) => {
50+
void navigator.clipboard?.writeText(text).then(() =>
51+
showToast({ variant: "success", title: language.t("fileTree.copied") }),
52+
)
53+
}
54+
55+
const markClip = (mode: "copy" | "cut") => {
56+
setClip({ mode, absolute: props.node.absolute })
57+
showToast({ variant: "success", title: language.t(mode === "copy" ? "fileTree.copied" : "fileTree.cut") })
58+
}
59+
60+
const paste = async () => {
61+
const current = clip()
62+
if (!current) return
63+
const destDir = props.node.absolute
64+
const res =
65+
current.mode === "copy"
66+
? await desktopApi()?.fileOps?.copy(root(), current.absolute, destDir)
67+
: await desktopApi()?.fileOps?.move(root(), current.absolute, destDir)
68+
if (!res) return
69+
if (res.ok) {
70+
if (current.mode === "cut") setClip(null)
71+
showToast({ variant: "success", title: language.t("fileTree.pasted") })
72+
refresh()
73+
return
74+
}
75+
showToast({ variant: "error", title: language.t("fileTree.pasteFailed"), description: res.error })
76+
}
77+
78+
const remove = async () => {
79+
if (!window.confirm(language.t("fileTree.deleteConfirm", { name: props.node.name }))) return
80+
report(await desktopApi()?.fileOps?.remove(root(), props.node.absolute), "fileTree.deleted", "fileTree.deleteFailed")
81+
}
82+
83+
const archive = async () => {
84+
report(await desktopApi()?.fileOps?.archive(root(), props.node.absolute), "fileTree.archived", "fileTree.archiveFailed")
85+
}
86+
87+
const extract = async () => {
88+
report(await desktopApi()?.fileOps?.extract(root(), props.node.absolute), "fileTree.extracted", "fileTree.extractFailed")
89+
}
90+
91+
return (
92+
<>
93+
<ContextMenu.Item onSelect={() => copyText(props.node.path)}>
94+
<ContextMenu.ItemLabel>{language.t("fileTree.copyRelativePath")}</ContextMenu.ItemLabel>
95+
</ContextMenu.Item>
96+
<ContextMenu.Item onSelect={() => copyText(props.node.absolute)}>
97+
<ContextMenu.ItemLabel>{language.t("fileTree.copyAbsolutePath")}</ContextMenu.ItemLabel>
98+
</ContextMenu.Item>
99+
<ContextMenu.Separator />
100+
<ContextMenu.Item onSelect={() => markClip("copy")} disabled={!localFs()}>
101+
<ContextMenu.ItemLabel>{language.t("fileTree.copy")}</ContextMenu.ItemLabel>
102+
</ContextMenu.Item>
103+
<ContextMenu.Item onSelect={() => markClip("cut")} disabled={!localFs()}>
104+
<ContextMenu.ItemLabel>{language.t("fileTree.cut")}</ContextMenu.ItemLabel>
105+
</ContextMenu.Item>
106+
<Show when={canPaste({ nodeType: props.node.type, hasClip: Boolean(clip()), localFs: localFs() })}>
107+
<ContextMenu.Item onSelect={paste}>
108+
<ContextMenu.ItemLabel>{language.t("fileTree.paste")}</ContextMenu.ItemLabel>
109+
</ContextMenu.Item>
110+
</Show>
111+
<ContextMenu.Separator />
112+
<ContextMenu.Item onSelect={remove} disabled={!localFs()}>
113+
<ContextMenu.ItemLabel>{language.t("common.delete")}</ContextMenu.ItemLabel>
114+
</ContextMenu.Item>
115+
<ContextMenu.Item onSelect={() => props.onRename()} disabled={!localFs()}>
116+
<ContextMenu.ItemLabel>{language.t("common.rename")}</ContextMenu.ItemLabel>
117+
</ContextMenu.Item>
118+
<ContextMenu.Separator />
119+
<ContextMenu.Item onSelect={archive} disabled={!localFs()}>
120+
<ContextMenu.ItemLabel>{language.t("fileTree.archive")}</ContextMenu.ItemLabel>
121+
</ContextMenu.Item>
122+
<Show when={canExtract({ nodeType: props.node.type, name: props.node.name })}>
123+
<ContextMenu.Item onSelect={extract} disabled={!localFs()}>
124+
<ContextMenu.ItemLabel>{language.t("fileTree.extract")}</ContextMenu.ItemLabel>
125+
</ContextMenu.Item>
126+
</Show>
127+
<Show when={canOpenTimeline({ nodeType: props.node.type, localFs: localFs() })}>
128+
<ContextMenu.Separator />
129+
<ContextMenu.Item onSelect={() => props.onOpenTimeline(props.node)}>
130+
<ContextMenu.ItemLabel>{language.t("fileTree.openTimeline")}</ContextMenu.ItemLabel>
131+
</ContextMenu.Item>
132+
</Show>
133+
</>
134+
)
135+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { canExtract, canOpenTimeline, canPaste, parentPath } from "./file-tree-menu"
3+
4+
// These rules encode the file-tree context menu's multi-platform / Server-Edition degradation
5+
// contract: local-only and destructive operations are gated on `localFs` (desktop bridge present
6+
// AND loopback sidecar), plus node-type / filename conditions. A change to the gating surfaces as
7+
// a focused failure here instead of only as a regression in the rendered menu.
8+
9+
describe("parentPath", () => {
10+
test("returns the directory portion of a nested path", () => {
11+
expect(parentPath("src/components/file-tree.tsx")).toBe("src/components")
12+
})
13+
14+
test("returns the top-level directory for a shallow file", () => {
15+
expect(parentPath("README.md")).toBe("")
16+
})
17+
18+
test("returns '' for a root-level path with no slash", () => {
19+
expect(parentPath("file")).toBe("")
20+
})
21+
22+
test("handles a trailing slash by slicing at the last separator", () => {
23+
// the menu calls parentPath(node.path) to refresh the containing directory after an op;
24+
// a directory node path like "src/sub/" still yields "src/sub"
25+
expect(parentPath("src/sub/")).toBe("src/sub")
26+
})
27+
})
28+
29+
describe("canPaste", () => {
30+
test("is offered on a directory with a clip and local fs available", () => {
31+
expect(canPaste({ nodeType: "directory", hasClip: true, localFs: true })).toBe(true)
32+
})
33+
34+
test("is hidden on a file even with a clip and local fs", () => {
35+
expect(canPaste({ nodeType: "file", hasClip: true, localFs: true })).toBe(false)
36+
})
37+
38+
test("is hidden when there is no clip to paste", () => {
39+
expect(canPaste({ nodeType: "directory", hasClip: false, localFs: true })).toBe(false)
40+
})
41+
42+
test("is hidden on the web build / remote Server Edition sidecar (no local fs)", () => {
43+
// paste is destructive (move on cut) — must not surface when the local bridge is unavailable
44+
expect(canPaste({ nodeType: "directory", hasClip: true, localFs: false })).toBe(false)
45+
})
46+
})
47+
48+
describe("canExtract", () => {
49+
test("is offered on a .zip file", () => {
50+
expect(canExtract({ nodeType: "file", name: "archive.zip" })).toBe(true)
51+
})
52+
53+
test("is case-insensitive on the .zip extension", () => {
54+
expect(canExtract({ nodeType: "file", name: "ARCHIVE.ZIP" })).toBe(true)
55+
expect(canExtract({ nodeType: "file", name: "Archive.Zip" })).toBe(true)
56+
})
57+
58+
test("is hidden on a non-zip file", () => {
59+
expect(canExtract({ nodeType: "file", name: "notes.txt" })).toBe(false)
60+
expect(canExtract({ nodeType: "file", name: "tar.gz" })).toBe(false)
61+
})
62+
63+
test("is hidden on a directory even if named *.zip", () => {
64+
expect(canExtract({ nodeType: "directory", name: "bundle.zip" })).toBe(false)
65+
})
66+
})
67+
68+
describe("canOpenTimeline", () => {
69+
test("is offered on a file when local fs is available (local git binary)", () => {
70+
expect(canOpenTimeline({ nodeType: "file", localFs: true })).toBe(true)
71+
})
72+
73+
test("is hidden on a directory (git log is per-file)", () => {
74+
expect(canOpenTimeline({ nodeType: "directory", localFs: true })).toBe(false)
75+
})
76+
77+
test("is hidden on the web build / remote Server Edition sidecar (no local git)", () => {
78+
expect(canOpenTimeline({ nodeType: "file", localFs: false })).toBe(false)
79+
})
80+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Pure decision logic for the file-tree context menu, kept in a .ts module so it can be unit-tested
2+
// without importing the Solid component (which pulls in context providers and UI primitives).
3+
//
4+
// These rules encode the multi-platform / Server-Edition degradation contract: destructive and
5+
// local-only operations (copy/cut/paste/delete/rename/archive/extract/timeline) are gated on
6+
// `localFs` (desktop bridge present AND sidecar on loopback), and a few items additionally depend
7+
// on node type or filename. Keeping them here means a change to the gating rules surfaces as a
8+
// focused test failure instead of only as a regression in the rendered menu.
9+
10+
export type FileNodeType = "file" | "directory"
11+
12+
/** The parent directory path of a POSIX-style tree path ("" for top-level). */
13+
export function parentPath(p: string): string {
14+
const idx = p.lastIndexOf("/")
15+
return idx === -1 ? "" : p.slice(0, idx)
16+
}
17+
18+
/** Paste is offered only on a directory, when a clip exists, and local fs ops are available. */
19+
export function canPaste(input: { nodeType: FileNodeType; hasClip: boolean; localFs: boolean }): boolean {
20+
return input.nodeType === "directory" && input.hasClip && input.localFs
21+
}
22+
23+
/** Extract is offered only on a file whose name ends with .zip (case-insensitive). */
24+
export function canExtract(input: { nodeType: FileNodeType; name: string }): boolean {
25+
return input.nodeType === "file" && /\.zip$/i.test(input.name)
26+
}
27+
28+
/** The git timeline is offered only on a file with local fs ops available (local git binary). */
29+
export function canOpenTimeline(input: { nodeType: FileNodeType; localFs: boolean }): boolean {
30+
return input.nodeType === "file" && input.localFs
31+
}

0 commit comments

Comments
 (0)