From e35f0f617f7a41518e75d8f506f504942e6de610 Mon Sep 17 00:00:00 2001 From: thomas-yanga Date: Fri, 24 Jul 2026 14:58:12 +0800 Subject: [PATCH 1/2] 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 --- .github/workflows/test.yml | 2 + packages/app/src/app.tsx | 6 +- .../src/components/file-tree-context-menu.tsx | 135 ++++++ .../app/src/components/file-tree-menu.test.ts | 80 ++++ packages/app/src/components/file-tree-menu.ts | 31 ++ packages/app/src/components/file-tree.tsx | 199 ++++++-- .../src/components/git-timeline-dialog.tsx | 73 +++ packages/app/src/context/file.tsx | 13 + packages/app/src/i18n/en.ts | 23 + packages/app/src/i18n/zh.ts | 23 + packages/app/src/utils/desktop-api.test.ts | 61 +++ packages/app/src/utils/desktop-api.ts | 48 ++ packages/desktop/package.json | 2 + .../desktop/src/main/close-to-tray.test.ts | 25 + packages/desktop/src/main/close-to-tray.ts | 14 + packages/desktop/src/main/file-ops.test.ts | 448 ++++++++++++++++++ packages/desktop/src/main/file-ops.ts | 210 ++++++++ packages/desktop/src/main/git.test.ts | 121 +++++ packages/desktop/src/main/git.ts | 78 +++ packages/desktop/src/main/index.ts | 24 + packages/desktop/src/main/ipc.ts | 39 ++ packages/desktop/src/main/power.ts | 16 + packages/desktop/src/main/tray.ts | 68 +++ packages/desktop/src/main/windows.ts | 31 +- packages/desktop/src/preload/index.ts | 16 +- packages/desktop/src/preload/types.ts | 18 + turbo.json | 7 + 27 files changed, 1765 insertions(+), 46 deletions(-) create mode 100644 packages/app/src/components/file-tree-context-menu.tsx create mode 100644 packages/app/src/components/file-tree-menu.test.ts create mode 100644 packages/app/src/components/file-tree-menu.ts create mode 100644 packages/app/src/components/git-timeline-dialog.tsx create mode 100644 packages/app/src/utils/desktop-api.test.ts create mode 100644 packages/app/src/utils/desktop-api.ts create mode 100644 packages/desktop/src/main/close-to-tray.test.ts create mode 100644 packages/desktop/src/main/close-to-tray.ts create mode 100644 packages/desktop/src/main/file-ops.test.ts create mode 100644 packages/desktop/src/main/file-ops.ts create mode 100644 packages/desktop/src/main/git.test.ts create mode 100644 packages/desktop/src/main/git.ts create mode 100644 packages/desktop/src/main/power.ts create mode 100644 packages/desktop/src/main/tray.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09ba634d..e4849069 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,8 @@ jobs: host: blacksmith-4vcpu-ubuntu-2404 - name: windows host: blacksmith-4vcpu-windows-2025 + - name: macos + host: macos-14 runs-on: ${{ matrix.settings.host }} defaults: run: diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0da7afc6..dd00f9c7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -31,6 +31,7 @@ import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { DebugProvider } from "@/context/debug" import { FileProvider } from "@/context/file" +import type { DesktopApi } from "@/utils/desktop-api" import { GatewayProvider } from "@/context/gateway" import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" @@ -76,10 +77,7 @@ declare global { __DEEPAGENT_CODE__?: { deepLinks?: string[] } - api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise - exportDebugLogs?: (options?: { windowMs?: number; pick?: boolean }) => Promise - } + api?: DesktopApi } } diff --git a/packages/app/src/components/file-tree-context-menu.tsx b/packages/app/src/components/file-tree-context-menu.tsx new file mode 100644 index 00000000..4459e451 --- /dev/null +++ b/packages/app/src/components/file-tree-context-menu.tsx @@ -0,0 +1,135 @@ +import { createSignal, Show } from "solid-js" +import { ContextMenu } from "@deepagent-code/ui/context-menu" +import type { FileNode } from "@deepagent-code/sdk/v2" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { showToast } from "@/utils/toast" +import { desktopApi, isLocalFilesystemOp } from "@/utils/desktop-api" +import { canExtract, canOpenTimeline, canPaste, parentPath } from "./file-tree-menu" + +// Process-local clipboard for copy/cut → paste between file-tree nodes. Lives for the renderer +// lifetime; cut entries are cleared after a successful paste. +type Clip = { mode: "copy" | "cut"; absolute: string } +const [clip, setClip] = createSignal(null) + +export function FileTreeMenuContent(props: { + node: FileNode + onRename: () => void + onOpenTimeline: (node: FileNode) => void +}) { + const file = useFile() + const language = useLanguage() + + // File-ops and the git timeline run in the desktop main process against the local filesystem. + // They are only available on the desktop build AND when the connected sidecar is local + // (loopback). On the web build or against a remote Server Edition, these menu items degrade to + // disabled so the user never triggers an operation that would fail or touch the wrong host. + const localFs = () => isLocalFilesystemOp({ desktop: Boolean(desktopApi()), localSidecar: file.isLocalSidecar() }) + const root = () => file.directory() + + const refresh = () => { + void file.tree.refresh(parentPath(props.node.path)) + if (props.node.type === "directory") void file.tree.refresh(props.node.path) + } + + const report = ( + res: { ok: boolean; error?: string } | undefined, + okKey: string, + errKey: string, + ) => { + if (!res) return + if (res.ok) { + showToast({ variant: "success", title: language.t(okKey) }) + refresh() + return + } + showToast({ variant: "error", title: language.t(errKey), description: res.error }) + } + + const copyText = (text: string) => { + void navigator.clipboard?.writeText(text).then(() => + showToast({ variant: "success", title: language.t("fileTree.copied") }), + ) + } + + const markClip = (mode: "copy" | "cut") => { + setClip({ mode, absolute: props.node.absolute }) + showToast({ variant: "success", title: language.t(mode === "copy" ? "fileTree.copied" : "fileTree.cut") }) + } + + const paste = async () => { + const current = clip() + if (!current) return + const destDir = props.node.absolute + const res = + current.mode === "copy" + ? await desktopApi()?.fileOps?.copy(root(), current.absolute, destDir) + : await desktopApi()?.fileOps?.move(root(), current.absolute, destDir) + if (!res) return + if (res.ok) { + if (current.mode === "cut") setClip(null) + showToast({ variant: "success", title: language.t("fileTree.pasted") }) + refresh() + return + } + showToast({ variant: "error", title: language.t("fileTree.pasteFailed"), description: res.error }) + } + + const remove = async () => { + if (!window.confirm(language.t("fileTree.deleteConfirm", { name: props.node.name }))) return + report(await desktopApi()?.fileOps?.remove(root(), props.node.absolute), "fileTree.deleted", "fileTree.deleteFailed") + } + + const archive = async () => { + report(await desktopApi()?.fileOps?.archive(root(), props.node.absolute), "fileTree.archived", "fileTree.archiveFailed") + } + + const extract = async () => { + report(await desktopApi()?.fileOps?.extract(root(), props.node.absolute), "fileTree.extracted", "fileTree.extractFailed") + } + + return ( + <> + copyText(props.node.path)}> + {language.t("fileTree.copyRelativePath")} + + copyText(props.node.absolute)}> + {language.t("fileTree.copyAbsolutePath")} + + + markClip("copy")} disabled={!localFs()}> + {language.t("fileTree.copy")} + + markClip("cut")} disabled={!localFs()}> + {language.t("fileTree.cut")} + + + + {language.t("fileTree.paste")} + + + + + {language.t("common.delete")} + + props.onRename()} disabled={!localFs()}> + {language.t("common.rename")} + + + + {language.t("fileTree.archive")} + + + + {language.t("fileTree.extract")} + + + + + props.onOpenTimeline(props.node)}> + {language.t("fileTree.openTimeline")} + + + + ) +} diff --git a/packages/app/src/components/file-tree-menu.test.ts b/packages/app/src/components/file-tree-menu.test.ts new file mode 100644 index 00000000..54182459 --- /dev/null +++ b/packages/app/src/components/file-tree-menu.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { canExtract, canOpenTimeline, canPaste, parentPath } from "./file-tree-menu" + +// These rules encode the file-tree context menu's multi-platform / Server-Edition degradation +// contract: local-only and destructive operations are gated on `localFs` (desktop bridge present +// AND loopback sidecar), plus node-type / filename conditions. A change to the gating surfaces as +// a focused failure here instead of only as a regression in the rendered menu. + +describe("parentPath", () => { + test("returns the directory portion of a nested path", () => { + expect(parentPath("src/components/file-tree.tsx")).toBe("src/components") + }) + + test("returns the top-level directory for a shallow file", () => { + expect(parentPath("README.md")).toBe("") + }) + + test("returns '' for a root-level path with no slash", () => { + expect(parentPath("file")).toBe("") + }) + + test("handles a trailing slash by slicing at the last separator", () => { + // the menu calls parentPath(node.path) to refresh the containing directory after an op; + // a directory node path like "src/sub/" still yields "src/sub" + expect(parentPath("src/sub/")).toBe("src/sub") + }) +}) + +describe("canPaste", () => { + test("is offered on a directory with a clip and local fs available", () => { + expect(canPaste({ nodeType: "directory", hasClip: true, localFs: true })).toBe(true) + }) + + test("is hidden on a file even with a clip and local fs", () => { + expect(canPaste({ nodeType: "file", hasClip: true, localFs: true })).toBe(false) + }) + + test("is hidden when there is no clip to paste", () => { + expect(canPaste({ nodeType: "directory", hasClip: false, localFs: true })).toBe(false) + }) + + test("is hidden on the web build / remote Server Edition sidecar (no local fs)", () => { + // paste is destructive (move on cut) — must not surface when the local bridge is unavailable + expect(canPaste({ nodeType: "directory", hasClip: true, localFs: false })).toBe(false) + }) +}) + +describe("canExtract", () => { + test("is offered on a .zip file", () => { + expect(canExtract({ nodeType: "file", name: "archive.zip" })).toBe(true) + }) + + test("is case-insensitive on the .zip extension", () => { + expect(canExtract({ nodeType: "file", name: "ARCHIVE.ZIP" })).toBe(true) + expect(canExtract({ nodeType: "file", name: "Archive.Zip" })).toBe(true) + }) + + test("is hidden on a non-zip file", () => { + expect(canExtract({ nodeType: "file", name: "notes.txt" })).toBe(false) + expect(canExtract({ nodeType: "file", name: "tar.gz" })).toBe(false) + }) + + test("is hidden on a directory even if named *.zip", () => { + expect(canExtract({ nodeType: "directory", name: "bundle.zip" })).toBe(false) + }) +}) + +describe("canOpenTimeline", () => { + test("is offered on a file when local fs is available (local git binary)", () => { + expect(canOpenTimeline({ nodeType: "file", localFs: true })).toBe(true) + }) + + test("is hidden on a directory (git log is per-file)", () => { + expect(canOpenTimeline({ nodeType: "directory", localFs: true })).toBe(false) + }) + + test("is hidden on the web build / remote Server Edition sidecar (no local git)", () => { + expect(canOpenTimeline({ nodeType: "file", localFs: false })).toBe(false) + }) +}) diff --git a/packages/app/src/components/file-tree-menu.ts b/packages/app/src/components/file-tree-menu.ts new file mode 100644 index 00000000..8b5ce560 --- /dev/null +++ b/packages/app/src/components/file-tree-menu.ts @@ -0,0 +1,31 @@ +// Pure decision logic for the file-tree context menu, kept in a .ts module so it can be unit-tested +// without importing the Solid component (which pulls in context providers and UI primitives). +// +// These rules encode the multi-platform / Server-Edition degradation contract: destructive and +// local-only operations (copy/cut/paste/delete/rename/archive/extract/timeline) are gated on +// `localFs` (desktop bridge present AND sidecar on loopback), and a few items additionally depend +// on node type or filename. Keeping them here means a change to the gating rules surfaces as a +// focused test failure instead of only as a regression in the rendered menu. + +export type FileNodeType = "file" | "directory" + +/** The parent directory path of a POSIX-style tree path ("" for top-level). */ +export function parentPath(p: string): string { + const idx = p.lastIndexOf("/") + return idx === -1 ? "" : p.slice(0, idx) +} + +/** Paste is offered only on a directory, when a clip exists, and local fs ops are available. */ +export function canPaste(input: { nodeType: FileNodeType; hasClip: boolean; localFs: boolean }): boolean { + return input.nodeType === "directory" && input.hasClip && input.localFs +} + +/** Extract is offered only on a file whose name ends with .zip (case-insensitive). */ +export function canExtract(input: { nodeType: FileNodeType; name: string }): boolean { + return input.nodeType === "file" && /\.zip$/i.test(input.name) +} + +/** The git timeline is offered only on a file with local fs ops available (local git binary). */ +export function canOpenTimeline(input: { nodeType: FileNodeType; localFs: boolean }): boolean { + return input.nodeType === "file" && input.localFs +} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 46d3e6e3..c4ffc344 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,11 +1,20 @@ import { useFile } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import { Collapsible } from "@deepagent-code/ui/collapsible" +import { ContextMenu } from "@deepagent-code/ui/context-menu" import { FileIcon } from "@deepagent-code/ui/file-icon" import { Icon } from "@deepagent-code/ui/icon" +import { InlineInput } from "@deepagent-code/ui/inline-input" +import { useDialog } from "@deepagent-code/ui/context/dialog" +import { FileTreeMenuContent } from "./file-tree-context-menu" +import { GitTimelineDialog } from "./git-timeline-dialog" +import { useLanguage } from "@/context/language" +import { showToast } from "@/utils/toast" +import { desktopApi, isLocalFilesystemOp } from "@/utils/desktop-api" import { createEffect, createMemo, + createSignal, For, Match, on, @@ -16,7 +25,6 @@ import { type ComponentProps, type ParentProps, } from "solid-js" -import { Dynamic } from "solid-js/web" import type { FileNode } from "@deepagent-code/sdk/v2" const MAX_DEPTH = 128 @@ -119,6 +127,9 @@ const FileTreeNode = ( kinds?: ReadonlyMap marks?: Set as?: "div" | "button" + renaming?: () => string | null + setRenaming?: (path: string | null) => void + onOpenTimeline?: (node: FileNode) => void }, ) => { const [local, rest] = splitProps(p, [ @@ -133,7 +144,12 @@ const FileTreeNode = ( "children", "class", "classList", + "renaming", + "setRenaming", + "onOpenTimeline", ]) + const language = useLanguage() + const file = useFile() const kind = () => visibleKind(local.node, local.kinds, local.marks) const active = () => !!kind() && !local.node.ignored const color = () => { @@ -142,51 +158,124 @@ const FileTreeNode = ( return kindTextColor(value) } + const editing = () => !!local.renaming && local.renaming() === local.node.path + const [draft, setDraft] = createSignal(local.node.name) + createEffect(() => { + if (editing()) setDraft(local.node.name) + }) + + const commitRename = async (next: string) => { + const name = next.trim() + local.setRenaming?.(null) + if (!name || name === local.node.name) return + const res = await desktopApi()?.fileOps?.rename(file.directory(), local.node.absolute, name) + if (!res) return + if (res.ok) { + showToast({ variant: "success", title: language.t("fileTree.renamed") }) + const idx = local.node.path.lastIndexOf("/") + void file.tree.refresh(idx === -1 ? "" : local.node.path.slice(0, idx)) + return + } + showToast({ variant: "error", title: language.t("fileTree.renameFailed"), description: res.error }) + } + + // Defer rename until the context menu has finished closing so its focus-return doesn't yank focus + // back from the inline editor. + let pendingRename = false + return ( - { - if (!local.draggable) return - event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" - withFileDragImage(event) + { + if (!open && pendingRename) { + pendingRename = false + requestAnimationFrame(() => local.setRenaming?.(local.node.path)) + } }} - {...rest} > - {local.children} - { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...(rest as Omit, "onContextMenu">)} > - {local.node.name} - - {(() => { - const value = kind() - if (!value) return null - if (local.node.type === "file") { - return ( - - {kindLabel(value)} - - ) - } - return
- })()} - + {local.children} + { + requestAnimationFrame(() => { + el?.focus() + el?.select() + }) + }} + value={draft()} + class="flex-1 min-w-0 text-12-medium bg-surface-base-active rounded px-1 -mx-1 outline-none border border-border-weak-base" + onClick={(event) => event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onInput={(event) => setDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + void commitRename(draft()) + } + if (event.key === "Escape") local.setRenaming?.(null) + }} + onBlur={() => void commitRename(draft())} + /> + } + > + + {local.node.name} + + + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value)} + + ) + } + return
+ })()} + + + + { + pendingRename = true + }} + onOpenTimeline={(node) => local.onOpenTimeline?.(node)} + /> + + + ) } @@ -207,11 +296,33 @@ export default function FileTree(props: { _deeps?: Map _kinds?: ReadonlyMap _chain?: readonly string[] + _renaming?: () => string | null + _setRenaming?: (path: string | null) => void }) { const file = useFile() + const dialog = useDialog() const level = props.level ?? 0 const draggable = () => props.draggable ?? true + // Shared rename state across the recursive tree: the top-level call creates the signal, children + // receive it via _renaming/_setRenaming so only one node is edited at a time. + const [ownRenaming, ownSetRenaming] = createSignal(null) + const renaming = props._renaming ?? ownRenaming + const setRenaming = props._setRenaming ?? ownSetRenaming + + const onOpenTimeline = (node: FileNode) => { + void dialog.show( + () => ( + + ), + ) + } + const key = (p: string) => file .normalize(p) @@ -412,6 +523,9 @@ export default function FileTree(props: { draggable={draggable()} kinds={kinds()} marks={marks()} + renaming={renaming} + setRenaming={setRenaming} + onOpenTimeline={onOpenTimeline} >
@@ -445,6 +559,8 @@ export default function FileTree(props: { _deeps={deeps()} _kinds={kinds()} _chain={chain} + _renaming={renaming} + _setRenaming={setRenaming} /> @@ -459,6 +575,9 @@ export default function FileTree(props: { draggable={draggable()} kinds={kinds()} marks={marks()} + renaming={renaming} + setRenaming={setRenaming} + onOpenTimeline={onOpenTimeline} as="button" type="button" onClick={() => props.onFileClick?.(node)} diff --git a/packages/app/src/components/git-timeline-dialog.tsx b/packages/app/src/components/git-timeline-dialog.tsx new file mode 100644 index 00000000..41871dbc --- /dev/null +++ b/packages/app/src/components/git-timeline-dialog.tsx @@ -0,0 +1,73 @@ +import { createResource, For, Show } from "solid-js" +import { Dialog } from "@deepagent-code/ui/dialog" +import { useLanguage } from "@/context/language" +import { desktopApi } from "@/utils/desktop-api" + +export function GitTimelineDialog(props: { + workDir: string + relPath: string + name: string + local: boolean +}): ReturnType { + const language = useLanguage() + const [result] = createResource(async () => { + const api = desktopApi() + // The git timeline reads from a local git binary in the desktop main process. On the web build + // or against a remote Server Edition sidecar, the local filesystem is not the workspace, so + // surface that explicitly instead of running git against a path that doesn't exist locally. + if (!api || !props.local) return { ok: false as const, error: "desktop-only", entries: [] } + return api.git?.fileLog(props.workDir, props.relPath) + }) + + return ( + +
+ + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + 0} + fallback={ + + {result()?.error ?? language.t("fileTree.timeline.error")} +
+ } + > +
+ {language.t("fileTree.timeline.empty")} +
+ + } + > + + {(entry) => ( +
+ {entry.hash.slice(0, 8)} +
+
{entry.subject}
+
+ {entry.author} · {entry.date} +
+
+
+ )} +
+ + +
+ + ) +} diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6f2171d7..3dcd23f9 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -64,6 +64,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) + // The desktop file-ops/git bridge runs in the local main process, so it is only valid when the + // connected sidecar is on the loopback (a remote Server Edition connection must not let the + // local bridge touch paths that only exist on the remote host). + const isLocalSidecar = createMemo(() => { + try { + const url = new URL(serverSDK.url) + return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" + } catch { + return false + } + }) const tabs = layout.tabs(() => SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)), ) @@ -252,6 +263,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return { ready: () => view().ready(), + directory: () => scope(), + isLocalSidecar, normalize: path.normalize, tab: path.tab, pathFromTab: path.pathFromTab, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 50b17750..07bdc19e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -808,6 +808,29 @@ export const dict = { "session.files.create.cancel": "Cancel", "session.files.create.failed": "Failed to create file", "session.files.createFolder.failed": "Failed to create folder", + "fileTree.copyRelativePath": "Copy Relative Path", + "fileTree.copyAbsolutePath": "Copy Absolute Path", + "fileTree.copy": "Copy", + "fileTree.cut": "Cut", + "fileTree.paste": "Paste", + "fileTree.copied": "Copied to clipboard", + "fileTree.pasted": "Pasted", + "fileTree.pasteFailed": "Failed to paste", + "fileTree.deleted": "Deleted", + "fileTree.deleteFailed": "Failed to delete", + "fileTree.deleteConfirm": "Are you sure you want to delete \"{name}\"?", + "fileTree.renamed": "Renamed", + "fileTree.renameFailed": "Failed to rename", + "fileTree.archive": "Compress to ZIP", + "fileTree.archived": "Compressed", + "fileTree.archiveFailed": "Failed to compress", + "fileTree.extract": "Extract ZIP", + "fileTree.extracted": "Extracted", + "fileTree.extractFailed": "Failed to extract", + "fileTree.openTimeline": "Open Timeline", + "fileTree.timeline.title": "Timeline — {name}", + "fileTree.timeline.empty": "No git history for this file", + "fileTree.timeline.error": "Failed to load timeline", "session.panel.oversight": "Oversight", "session.panel.debug": "Debug", "session.panel.profile": "Profiler", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index aa481a7f..93fad193 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -711,6 +711,29 @@ export const dict = { "session.files.create.cancel": "取消", "session.files.create.failed": "创建失败", "session.files.createFolder.failed": "创建文件夹失败", + "fileTree.copyRelativePath": "复制相对路径", + "fileTree.copyAbsolutePath": "复制绝对路径", + "fileTree.copy": "复制", + "fileTree.cut": "剪切", + "fileTree.paste": "粘贴", + "fileTree.copied": "已复制到剪贴板", + "fileTree.pasted": "已粘贴", + "fileTree.pasteFailed": "粘贴失败", + "fileTree.deleted": "已删除", + "fileTree.deleteFailed": "删除失败", + "fileTree.deleteConfirm": "确定要删除“{name}”吗?", + "fileTree.renamed": "已重命名", + "fileTree.renameFailed": "重命名失败", + "fileTree.archive": "压缩为 ZIP", + "fileTree.archived": "已压缩", + "fileTree.archiveFailed": "压缩失败", + "fileTree.extract": "解压 ZIP", + "fileTree.extracted": "已解压", + "fileTree.extractFailed": "解压失败", + "fileTree.openTimeline": "打开时间线", + "fileTree.timeline.title": "时间线 — {name}", + "fileTree.timeline.empty": "该文件没有 Git 历史", + "fileTree.timeline.error": "加载时间线失败", "session.panel.oversight": "监督", "session.panel.debug": "调试", "session.panel.profile": "性能剖析", diff --git a/packages/app/src/utils/desktop-api.test.ts b/packages/app/src/utils/desktop-api.test.ts new file mode 100644 index 00000000..c2711e52 --- /dev/null +++ b/packages/app/src/utils/desktop-api.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { desktopApi, isDesktop, isLocalFilesystemOp } from "./desktop-api" + +// The accessor is a thin typed wrapper over the optional `window.api` injected by the desktop +// preload. Tests mutate the global between cases and restore it in afterEach. DesktopApi's fields +// are all optional, so an empty object is a valid bridge for these reference-equality checks. + +const original = (window as { api?: unknown }).api + +afterEach(() => { + if (original === undefined) delete (window as { api?: unknown }).api + else (window as { api?: unknown }).api = original +}) + +describe("desktopApi", () => { + test("returns undefined when the desktop bridge is absent (web build)", () => { + delete (window as { api?: unknown }).api + expect(desktopApi()).toBeUndefined() + }) + + test("returns the injected bridge when present (desktop build)", () => { + const api = {} + ;(window as { api?: unknown }).api = api + expect(desktopApi()).toBe(api) + }) +}) + +describe("isDesktop", () => { + test("is false in the web build", () => { + delete (window as { api?: unknown }).api + expect(isDesktop()).toBe(false) + }) + + test("is true once the desktop bridge is injected", () => { + ;(window as { api?: unknown }).api = {} + expect(isDesktop()).toBe(true) + }) +}) + +describe("isLocalFilesystemOp", () => { + // Gates every local-only file-tree operation (copy/cut/paste/delete/rename/archive/extract and + // the git timeline). The rule is the AND of two independent conditions: the desktop bridge must + // be present (Electron preload) AND the sidecar must be on loopback. A remote Server Edition + // connection must NOT let the local bridge touch paths that only exist on the remote host. + test("is true only when the desktop bridge is present AND the sidecar is loopback", () => { + expect(isLocalFilesystemOp({ desktop: true, localSidecar: true })).toBe(true) + }) + + test("is false on the web build (no desktop bridge) even for a local sidecar", () => { + expect(isLocalFilesystemOp({ desktop: false, localSidecar: true })).toBe(false) + }) + + test("is false on the desktop build when the sidecar is remote (Server Edition)", () => { + // a remote sidecar means the workspace paths do not exist locally; the bridge must stay idle + expect(isLocalFilesystemOp({ desktop: true, localSidecar: false })).toBe(false) + }) + + test("is false when neither condition holds", () => { + expect(isLocalFilesystemOp({ desktop: false, localSidecar: false })).toBe(false) + }) +}) diff --git a/packages/app/src/utils/desktop-api.ts b/packages/app/src/utils/desktop-api.ts new file mode 100644 index 00000000..0e6a5521 --- /dev/null +++ b/packages/app/src/utils/desktop-api.ts @@ -0,0 +1,48 @@ +// Typed accessor for the optional Electron desktop bridge (`window.api`). In the web build it is +// undefined; in the desktop build the preload script injects it. The global `Window.api` type is +// declared in `src/app.tsx` via `DesktopApi` so the shared app package stays free of desktop-only +// type dependencies. + +type FileOpResult = { ok: boolean; error?: string; path?: string } + +type FileOpsApi = { + copy: (root: string, source: string, destDir: string) => Promise + move: (root: string, source: string, destDir: string) => Promise + remove: (root: string, target: string) => Promise + rename: (root: string, target: string, nextName: string) => Promise + archive: (root: string, target: string) => Promise + extract: (root: string, zipPath: string) => Promise +} + +type GitLogEntry = { hash: string; author: string; date: string; subject: string } + +type GitApi = { + isTracked: (workDir: string, relPath: string) => Promise<{ ok: boolean; tracked: boolean; error?: string }> + fileLog: (workDir: string, relPath: string) => Promise<{ ok: boolean; entries: GitLogEntry[]; error?: string }> +} + +export type DesktopApi = { + setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + exportDebugLogs?: (options?: { windowMs?: number; pick?: boolean }) => Promise + fileOps?: FileOpsApi + git?: GitApi +} + +export function desktopApi(): DesktopApi | undefined { + return window.api +} + +export function isDesktop(): boolean { + return Boolean(desktopApi()) +} + +/** + * Whether local filesystem operations (the file-ops/git bridge) are usable. Requires BOTH the + * desktop bridge (Electron preload injected `window.api`) AND a loopback sidecar — a remote + * Server Edition connection must not let the local bridge touch paths that only exist on the + * remote host. Extracted as a pure function so the degradation rule is unit-testable and the + * three call sites (file-tree menu, timeline dialog, tree wiring) share one definition. + */ +export function isLocalFilesystemOp(input: { desktop: boolean; localSidecar: boolean }): boolean { + return input.desktop && input.localSidecar +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 531c7962..d3299725 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -16,6 +16,8 @@ "prebuild": "bun ./scripts/prebuild.ts", "build": "electron-vite build", "preview": "electron-vite preview", + "test": "bun test ./src", + "test:ci": "mkdir -p .artifacts/unit && bun test ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "package": "bun ./scripts/package.ts", "package:mac": "bun ./scripts/package.ts --mac", "package:win": "electron-builder --win --config electron-builder.config.ts", diff --git a/packages/desktop/src/main/close-to-tray.test.ts b/packages/desktop/src/main/close-to-tray.test.ts new file mode 100644 index 00000000..d851bf0e --- /dev/null +++ b/packages/desktop/src/main/close-to-tray.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { shouldHideOnClose } from "./close-to-tray" + +// Covers the multi-platform close-to-tray gating without the Electron runtime. The same decision +// runs in windows.ts on every window "close" event. + +describe("shouldHideOnClose", () => { + test("hides to tray when not quitting and a tray is available", () => { + expect(shouldHideOnClose({ isQuitting: false, trayAvailable: true })).toBe(true) + }) + + test("quits normally (does not hide) when no tray is available — Linux GNOME fallback", () => { + // This is the critical Linux case: without a tray host, hiding would strand the window. + expect(shouldHideOnClose({ isQuitting: false, trayAvailable: false })).toBe(false) + }) + + test("quits normally when an explicit quit is in progress, even with a tray", () => { + // tray "Quit", Cmd+Q, and before-quit all set isQuitting=true to bypass close-to-tray. + expect(shouldHideOnClose({ isQuitting: true, trayAvailable: true })).toBe(false) + }) + + test("quits normally when both quitting and no tray", () => { + expect(shouldHideOnClose({ isQuitting: true, trayAvailable: false })).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/close-to-tray.ts b/packages/desktop/src/main/close-to-tray.ts new file mode 100644 index 00000000..aabcf6e9 --- /dev/null +++ b/packages/desktop/src/main/close-to-tray.ts @@ -0,0 +1,14 @@ +/** + * Pure decision logic for close-to-tray behavior, extracted so it can be unit-tested without the + * Electron runtime. + * + * Closing the main window hides it to the tray ONLY when all of: + * - an explicit quit is not in progress (`isQuitting` is false) + * - a system tray was successfully created (`trayAvailable` is true) + * + * Without a tray (e.g. Linux GNOME default, which ships no StatusNotifierItem host), closing must + * quit normally — otherwise the window would be hidden with no way to recover it. + */ +export function shouldHideOnClose(input: { isQuitting: boolean; trayAvailable: boolean }): boolean { + return !input.isQuitting && input.trayAvailable +} diff --git a/packages/desktop/src/main/file-ops.test.ts b/packages/desktop/src/main/file-ops.test.ts new file mode 100644 index 00000000..47574ada --- /dev/null +++ b/packages/desktop/src/main/file-ops.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from "node:fs/promises" +import { tmpdir, homedir } from "node:os" +import { join } from "node:path" +import { BlobReader, ZipWriter, BlobWriter } from "@zip.js/zip.js" +import { archivePath, assertWithinRoot, copyPath, extractPath, guardFileOpCall, movePath, removePath, renamePath } from "./file-ops" + +// Each test gets a fresh temp directory cleaned up in finally. We use real fs + real zip.js so the +// test exercises the same code path as production instead of re-implementing the logic. + +async function tmpDir() { + const dir = await mkdtemp(join(tmpdir(), "deepagent-code-fileops-")) + return dir +} + +async function writeText(path: string, content: string) { + await mkdir(join(path, ".."), { recursive: true }) + await writeFile(path, content, "utf8") +} + +describe("copyPath", () => { + test("copies a single file into a destination directory", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "src.txt"), "hello") + await mkdir(join(dir, "dest")) + const res = await copyPath(join(dir, "src.txt"), join(dir, "dest")) + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "dest", "src.txt"), "utf8")).toBe("hello") + // source remains + expect(await readFile(join(dir, "src.txt"), "utf8")).toBe("hello") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("recursively copies a directory tree", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "root", "a.txt"), "a") + await writeText(join(dir, "root", "sub", "b.txt"), "b") + const res = await copyPath(join(dir, "root"), join(dir, "out")) + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "out", "root", "a.txt"), "utf8")).toBe("a") + expect(await readFile(join(dir, "out", "root", "sub", "b.txt"), "utf8")).toBe("b") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("picks a non-colliding name when the target already exists", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "src.txt"), "first") + await writeText(join(dir, "dest", "src.txt"), "second") + const res = await copyPath(join(dir, "src.txt"), join(dir, "dest")) + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "dest", "src.txt"), "utf8")).toBe("second") + expect(await readFile(join(dir, "dest", "src (1).txt"), "utf8")).toBe("first") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("movePath", () => { + test("moves a file and removes the source", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "src.txt"), "data") + await mkdir(join(dir, "dest")) + const res = await movePath(join(dir, "src.txt"), join(dir, "dest")) + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "dest", "src.txt"), "utf8")).toBe("data") + await expect(stat(join(dir, "src.txt"))).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("falls back to copy+remove when rename fails with EXDEV (cross-device)", async () => { + // Cross-device moves (Windows C:→D:, Linux cross-mount) make fs.rename throw EXDEV. We trigger + // a REAL cross-device rename by moving between /dev/shm (tmpfs) and the OS tmpdir (disk), so + // the test exercises the actual fallback path rather than a stub. Skipped where /dev/shm is + // unavailable (Windows, some CI sandboxes) or on the same device as tmpdir. + const { existsSync } = await import("node:fs") + const shmDir = "/dev/shm" + if (!existsSync(shmDir)) return + let srcDir: string | undefined + let destDir: string | undefined + try { + srcDir = await mkdtemp(join(shmDir, "deepagent-code-exdev-")) + destDir = await tmpDir() + await writeText(join(srcDir, "src.txt"), "cross-device data") + // Sanity check that this environment actually produces EXDEV here; if not, the fallback is + // untestable here and we skip rather than pass vacuously. + const fs = await import("node:fs/promises") + try { + await fs.rename(join(srcDir, "src.txt"), join(destDir, "probe")) + // rename succeeded → same device → can't test EXDEV here + return + } catch (e) { + if ((e as { code?: string }).code !== "EXDEV") return + // restore the probe (it moved) so the real test starts clean + } + const res = await movePath(join(srcDir, "src.txt"), destDir) + expect(res.ok).toBe(true) + expect(await readFile(join(destDir, "src.txt"), "utf8")).toBe("cross-device data") + await expect(stat(join(srcDir, "src.txt"))).rejects.toThrow() + } finally { + if (srcDir) await rm(srcDir, { recursive: true, force: true }) + if (destDir) await rm(destDir, { recursive: true, force: true }) + } + }) +}) + +describe("removePath", () => { + test("deletes a file", async () => { + const dir = await tmpDir() + try { + const file = join(dir, "gone.txt") + await writeText(file, "x") + const res = await removePath(file) + expect(res.ok).toBe(true) + await expect(stat(file)).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("deletes a non-empty directory recursively", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "tree", "deep", "leaf.txt"), "x") + const res = await removePath(join(dir, "tree")) + expect(res.ok).toBe(true) + await expect(stat(join(dir, "tree"))).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("succeeds when the target does not exist", async () => { + const dir = await tmpDir() + try { + const res = await removePath(join(dir, "never-existed")) + expect(res.ok).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("renamePath", () => { + test("renames a file within its directory", async () => { + const dir = await tmpDir() + try { + const file = join(dir, "old.txt") + await writeText(file, "content") + const res = await renamePath(file, "new.txt") + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "new.txt"), "utf8")).toBe("content") + await expect(stat(file)).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("rejects an empty name", async () => { + const dir = await tmpDir() + try { + const file = join(dir, "old.txt") + await writeText(file, "content") + const res = await renamePath(file, " ") + expect(res.ok).toBe(false) + expect(res.error).toBeTruthy() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("rejects a name containing path separators", async () => { + const dir = await tmpDir() + try { + const file = join(dir, "old.txt") + await writeText(file, "content") + const res = await renamePath(file, "sub/new.txt") + expect(res.ok).toBe(false) + expect(res.error).toBeTruthy() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("rejects Windows-illegal filename characters", async () => { + const dir = await tmpDir() + try { + const file = join(dir, "old.txt") + await writeText(file, "content") + // Each of < > : " | ? * and control chars is rejected on every platform so fs.rename never + // reaches the OS with a name that would fail opaquely on Windows. + for (const bad of ["ab", "a:b", 'a"b', "a|b", "a?b", "a*b", "a\u0000b"]) { + const res = await renamePath(file, bad) + expect(res.ok).toBe(false) + expect(res.error).toBeTruthy() + } + // original file is untouched after all rejected attempts + expect(await readFile(file, "utf8")).toBe("content") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("rejects a name that collides with an existing entry", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "a.txt"), "a") + await writeText(join(dir, "b.txt"), "b") + const res = await renamePath(join(dir, "a.txt"), "b.txt") + expect(res.ok).toBe(false) + expect(res.error).toBeTruthy() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("archivePath", () => { + test("zips a single file into .zip", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "note.txt"), "archive me") + const res = await archivePath(join(dir, "note.txt")) + expect(res.ok).toBe(true) + expect(res.path).toBe(join(dir, "note.txt.zip")) + // zip is a real, non-empty file + const info = await stat(res.path!) + expect(info.size).toBeGreaterThan(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("zips a directory and preserves nested entries", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "project", "index.ts"), "export") + await writeText(join(dir, "project", "src", "util.ts"), "util") + const res = await archivePath(join(dir, "project")) + expect(res.ok).toBe(true) + expect(res.path).toBe(join(dir, "project.zip")) + + // round-trip: extract and verify contents match + const extracted = await extractPath(res.path!) + expect(extracted.ok).toBe(true) + expect(await readFile(join(extracted.path!, "project", "index.ts"), "utf8")).toBe("export") + expect(await readFile(join(extracted.path!, "project", "src", "util.ts"), "utf8")).toBe("util") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("picks a non-colliding zip name when one already exists", async () => { + const dir = await tmpDir() + try { + await writeText(join(dir, "f.txt"), "x") + await writeText(join(dir, "f.txt.zip"), "existing") + const res = await archivePath(join(dir, "f.txt")) + expect(res.ok).toBe(true) + expect(res.path).toBe(join(dir, "f.txt (1).zip")) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("extractPath", () => { + test("extracts a zip preserving directory structure", async () => { + const dir = await tmpDir() + try { + // build a zip with nested entries + const writer = new ZipWriter(new BlobWriter("application/zip")) + await writer.add("top.txt", new BlobReader(new Blob(["top-content"]))) + await writer.add("nested/deep.txt", new BlobReader(new Blob(["deep-content"]))) + const zipBlob = await writer.close() + const zipPath = join(dir, "bundle.zip") + await writeFile(zipPath, Buffer.from(await zipBlob.arrayBuffer())) + + const res = await extractPath(zipPath) + expect(res.ok).toBe(true) + expect(res.path).toBe(join(dir, "bundle")) + expect(await readFile(join(res.path!, "top.txt"), "utf8")).toBe("top-content") + expect(await readFile(join(res.path!, "nested", "deep.txt"), "utf8")).toBe("deep-content") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("ignores entries that escape the destination directory (path traversal)", async () => { + const dir = await tmpDir() + try { + const writer = new ZipWriter(new BlobWriter("application/zip")) + await writer.add("safe.txt", new BlobReader(new Blob(["safe"]))) + // malicious entry attempting to write outside the extract root + await writer.add("../escape.txt", new BlobReader(new Blob(["escaped"]))) + const zipBlob = await writer.close() + const zipPath = join(dir, "evil.zip") + await writeFile(zipPath, Buffer.from(await zipBlob.arrayBuffer())) + + const res = await extractPath(zipPath) + expect(res.ok).toBe(true) + // safe entry extracted + expect(await readFile(join(res.path!, "safe.txt"), "utf8")).toBe("safe") + // traversal entry did NOT escape to the parent + await expect(stat(join(dir, "escape.txt"))).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("assertWithinRoot", () => { + test("accepts paths inside the root", () => { + const root = join(tmpdir(), "workspace") + expect(assertWithinRoot(root, join(root, "a.txt"), join(root, "sub", "b.txt"))).toBeNull() + }) + + test("rejects a path that escapes the root via ..", () => { + const root = join(tmpdir(), "workspace") + const res = assertWithinRoot(root, join(root, "..", "secret.txt")) + expect(res?.ok).toBe(false) + expect(res?.error).toBeTruthy() + }) + + test("rejects an absolute path outside the root", () => { + const root = join(tmpdir(), "workspace") + const res = assertWithinRoot(root, "/etc/passwd") + expect(res?.ok).toBe(false) + }) + + test("rejects when any one of several paths escapes", () => { + const root = join(tmpdir(), "workspace") + const res = assertWithinRoot(root, join(root, "ok.txt"), join(root, "..", "..", "escape")) + expect(res?.ok).toBe(false) + }) +}) + +describe("rename path guard (cwd ≠ workspace root)", () => { + // The desktop main process calls process.chdir(homedir()) on startup, so cwd is the user's home + // directory, not the workspace. This block verifies that the rename path-check (assertWithinRoot + // on the target only, not on the bare nextName) works correctly under that condition. + + test("assertWithinRoot passes for an absolute target inside root regardless of cwd", () => { + const originalCwd = process.cwd() + process.chdir(homedir()) + try { + const root = join(tmpdir(), "workspace") + const target = join(root, "src", "old.txt") + expect(assertWithinRoot(root, target)).toBeNull() + } finally { + process.chdir(originalCwd) + } + }) + + test("assertWithinRoot rejects a bare filename when cwd is outside root — why rename must not guard nextName", () => { + const originalCwd = process.cwd() + process.chdir(homedir()) + try { + const root = join(tmpdir(), "workspace") + // A bare filename resolves against cwd (homedir), not root, so it always escapes. + // This is exactly why file-ops-rename guards only the target, never nextName. + expect(assertWithinRoot(root, "renamed.txt")?.ok).toBe(false) + } finally { + process.chdir(originalCwd) + } + }) + + test("renamePath succeeds with a bare filename when cwd is outside the workspace", async () => { + const originalCwd = process.cwd() + process.chdir(homedir()) + const dir = await tmpDir() + try { + const file = join(dir, "old.txt") + await writeText(file, "content") + const res = await renamePath(file, "new.txt") + expect(res.ok).toBe(true) + expect(await readFile(join(dir, "new.txt"), "utf8")).toBe("content") + await expect(stat(file)).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + process.chdir(originalCwd) + } + }) +}) + +describe("guardFileOpCall (IPC guard strategy)", () => { + // Mirrors the ipc.ts file-ops handlers. The generic `fileOp` wrapper passes every string arg to + // guardFileOpCall; `rename` passes only [target]. This block locks in WHY rename must not guard + // nextName: nextName is a bare filename that resolves against cwd (homedir in the desktop main + // process), so guarding it would always reject and break rename entirely. + + test("generic guard accepts all string args inside root", () => { + const root = join(tmpdir(), "workspace") + expect(guardFileOpCall(root, [join(root, "a.txt"), join(root, "sub", "b.txt")])).toBeNull() + }) + + test("generic guard rejects any arg escaping root", () => { + const root = join(tmpdir(), "workspace") + expect(guardFileOpCall(root, [join(root, "ok.txt"), join(root, "..", "escape")])?.ok).toBe(false) + }) + + test("rename guards only target, never the bare nextName", () => { + const root = join(tmpdir(), "workspace") + const target = join(root, "src", "old.txt") // absolute, inside root + const nextName = "renamed.txt" // bare filename, resolves against cwd (outside root) + + // target inside root → guard passes → renamePath would run + expect(guardFileOpCall(root, [target])).toBeNull() + // The trap: if nextName were wrongly guarded, it would be rejected (cwd is outside root). + expect(guardFileOpCall(root, [nextName])?.ok).toBe(false) + }) + + test("rename end-to-end: guard passes on target, then renamePath succeeds with the bare name", async () => { + // Reproduces the ipc.ts rename handler's full flow: + // guardFileOpCall(root, [target]) → renamePath(target, nextName) + // cwd is homedir (set by the desktop main process), proving the bare nextName works despite + // cwd ≠ root — which is exactly why nextName must be excluded from the guard. + const originalCwd = process.cwd() + process.chdir(homedir()) + const dir = await tmpDir() + try { + const root = dir + const target = join(root, "old.txt") + await writeText(target, "content") + const nextName = "new.txt" + + const guard = guardFileOpCall(root, [target]) + expect(guard).toBeNull() + const res = guard ? guard : await renamePath(target, nextName) + expect(res.ok).toBe(true) + expect(await readFile(join(root, "new.txt"), "utf8")).toBe("content") + await expect(stat(target)).rejects.toThrow() + } finally { + await rm(dir, { recursive: true, force: true }) + process.chdir(originalCwd) + } + }) +}) diff --git a/packages/desktop/src/main/file-ops.ts b/packages/desktop/src/main/file-ops.ts new file mode 100644 index 00000000..dcbe0b34 --- /dev/null +++ b/packages/desktop/src/main/file-ops.ts @@ -0,0 +1,210 @@ +import { promises as fs } from "node:fs" +import { basename, dirname, join, relative, resolve } from "node:path" +import { ZipReader, ZipWriter, BlobReader, BlobWriter } from "@zip.js/zip.js" + +export type FileOpResult = { ok: true } | { ok: false; error: string } + +/** + * Ensure every absolute path passed to a file-ops handler stays inside the workspace root. + * The renderer sends the workspace directory as `root`; any target/destination that resolves + * outside it is rejected before touching the filesystem, so a malicious or buggy renderer + * cannot use the local file-ops bridge to delete/move arbitrary files. + */ +export function assertWithinRoot(root: string, ...paths: string[]): FileOpResult | null { + const rootResolved = resolve(root) + for (const p of paths) { + const rel = relative(rootResolved, resolve(p)) + if (rel.startsWith("..")) return { ok: false, error: "Path is outside the workspace" } + } + return null +} + +/** + * Guard the positional path arguments of an IPC file-op call. Most handlers pass every string + * arg here; `rename` is the documented exception — its `nextName` is a bare filename that resolves + * against cwd (homedir in the desktop main process), so it must NOT be passed, only the `target`. + * Extracted so the IPC layer's guard strategy is unit-testable without the Electron runtime. + */ +export function guardFileOpCall(root: string, guardPaths: readonly string[]): FileOpResult | null { + return assertWithinRoot(root, ...guardPaths) +} + +/** Run an async file operation, returning a structured result instead of throwing. */ +async function attempt(fn: () => Promise): Promise { + try { + await fn() + return { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +async function readToBlob(filePath: string): Promise { + const buffer = await fs.readFile(filePath) + return new Blob([new Uint8Array(buffer)]) +} + +async function writeBlob(filePath: string, blob: Blob): Promise { + const buffer = Buffer.from(await blob.arrayBuffer()) + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer) +} + +/** Resolve a non-colliding output path by appending " (n)" before the extension. */ +async function uniquePath(target: string): Promise { + const dir = dirname(target) + const base = basename(target) + const dot = base.lastIndexOf(".") + const stem = dot > 0 ? base.slice(0, dot) : base + const ext = dot > 0 ? base.slice(dot) : "" + + let candidate = target + let n = 1 + while (await exists(candidate)) { + candidate = join(dir, `${stem} (${n})${ext}`) + n++ + } + return candidate +} + +async function exists(path: string): Promise { + try { + await fs.access(path) + return true + } catch { + return false + } +} + +export async function copyPath(source: string, destDir: string): Promise { + return attempt(async () => { + const base = basename(source) + const dest = await uniquePath(join(destDir, base)) + const stat = await fs.stat(source) + if (stat.isDirectory()) { + await fs.cp(source, dest, { recursive: true }) + } else { + await fs.copyFile(source, dest) + } + }) +} + +export async function movePath(source: string, destDir: string): Promise { + return attempt(async () => { + const base = basename(source) + const dest = await uniquePath(join(destDir, base)) + // fs.rename is atomic and cheap but fails with EXDEV across filesystems (e.g. Windows C:→D:, + // Linux cross-mount, or a tmpfs → disk move). Fall back to copy-then-remove so a cross-device + // move still succeeds instead of surfacing an opaque OS error to the user. + try { + await fs.rename(source, dest) + } catch (error) { + if (!isCrossDevice(error)) throw error + const stat = await fs.stat(source) + if (stat.isDirectory()) { + await fs.cp(source, dest, { recursive: true }) + } else { + await fs.copyFile(source, dest) + } + await fs.rm(source, { recursive: true, force: true }) + } + }) +} + +function isCrossDevice(error: unknown): boolean { + const code = (error as { code?: string } | undefined)?.code + return code === "EXDEV" || code === "ENOTSUP" +} + +export async function removePath(target: string): Promise { + return attempt(async () => { + await fs.rm(target, { recursive: true, force: true }) + }) +} + +// Windows reserves these characters in filenames. They are illegal on Win32 and problematic +// elsewhere, so we reject them up front with a clear message instead of letting fs.rename fail +// with an opaque OS error. +const ILLEGAL_NAME_CHARS = /[<>:"|?*\x00-\x1f]/ + +export async function renamePath(target: string, nextName: string): Promise { + return attempt(async () => { + const clean = nextName.trim() + if (!clean) throw new Error("Name cannot be empty") + if (clean.includes("/") || clean.includes("\\")) throw new Error("Name cannot contain path separators") + if (ILLEGAL_NAME_CHARS.test(clean)) throw new Error("Name contains illegal characters") + const dest = join(dirname(target), clean) + if (await exists(dest)) throw new Error(`"${clean}" already exists`) + await fs.rename(target, dest) + }) +} + +async function addDirectoryToZip(writer: ZipWriter, dir: string, prefix: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = join(dir, entry.name) + const entryName = `${prefix}/${entry.name}` + if (entry.isDirectory()) { + await addDirectoryToZip(writer, fullPath, entryName) + } else if (entry.isFile()) { + await writer.add(entryName, new BlobReader(await readToBlob(fullPath))) + } + } +} + +export async function archivePath(target: string): Promise { + try { + const stat = await fs.stat(target) + const base = basename(target) + const outPath = await uniquePath(join(dirname(target), `${base}.zip`)) + const writer = new ZipWriter(new BlobWriter("application/zip")) + if (stat.isDirectory()) { + await addDirectoryToZip(writer, target, base) + } else { + await writer.add(base, new BlobReader(await readToBlob(target))) + } + const zip = await writer.close() + await writeBlob(outPath, zip) + return { ok: true, path: outPath } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +/** Guard against zip entry paths that escape the destination directory (path traversal). */ +function safeJoin(root: string, entryPath: string): string | null { + const resolved = join(root, entryPath) + const rel = relative(root, resolved) + if (rel.startsWith("..") || join(root, rel) !== resolved) return null + return resolved +} + +export async function extractPath(zipPath: string): Promise { + try { + const base = basename(zipPath).replace(/\.zip$/i, "") + const outDir = await uniquePath(join(dirname(zipPath), base)) + await fs.mkdir(outDir, { recursive: true }) + + const reader = new ZipReader(new BlobReader(await readToBlob(zipPath))) + try { + const entries = await reader.getEntries() + for (const entry of entries) { + const target = safeJoin(outDir, entry.filename) + if (!target) continue + if (entry.directory) { + await fs.mkdir(target, { recursive: true }) + continue + } + if (!entry.getData) continue + await fs.mkdir(dirname(target), { recursive: true }) + const data = await entry.getData(new BlobWriter()) + await writeBlob(target, data) + } + } finally { + await reader.close() + } + return { ok: true, path: outDir } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/packages/desktop/src/main/git.test.ts b/packages/desktop/src/main/git.test.ts new file mode 100644 index 00000000..ebc3583c --- /dev/null +++ b/packages/desktop/src/main/git.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { fileLog, isTracked } from "./git" + +const exec = promisify(execFile) + +// Drives a real `git` binary against a throwaway repo so the test covers the actual git invocation +// path (argument formatting, record separator parsing, error classification) rather than a stub. + +async function withRepo(fn: (repo: string) => Promise) { + const repo = await mkdtemp(join(tmpdir(), "deepagent-code-git-")) + // Isolate git from any host identity/config so commits succeed without user setup. + await exec("git", ["-C", repo, "init", "-q"]) + await exec("git", ["-C", repo, "config", "user.email", "test@example.com"]) + await exec("git", ["-C", repo, "config", "user.name", "Test User"]) + try { + await fn(repo) + } finally { + await rm(repo, { recursive: true, force: true }) + } +} + +async function commit(repo: string, message: string) { + await exec("git", ["-C", repo, "add", "-A"]) + await exec("git", ["-C", repo, "commit", "-q", "-m", message]) +} + +describe("isTracked", () => { + test("reports true for a committed file", async () => { + await withRepo(async (repo) => { + await writeFile(join(repo, "tracked.txt"), "v1") + await commit(repo, "add file") + const res = await isTracked(repo, "tracked.txt") + expect(res.ok).toBe(true) + if (res.ok) expect(res.tracked).toBe(true) + }) + }) + + test("reports false for an untracked file", async () => { + await withRepo(async (repo) => { + // commit a seed file first, then create the untracked one WITHOUT staging it + await writeFile(join(repo, "other.txt"), "x") + await exec("git", ["-C", repo, "add", "other.txt"]) + await exec("git", ["-C", repo, "commit", "-q", "-m", "seed"]) + await writeFile(join(repo, "untracked.txt"), "nope") + const res = await isTracked(repo, "untracked.txt") + expect(res.ok).toBe(true) + if (res.ok) expect(res.tracked).toBe(false) + }) + }) + + test("reports false (not an error) outside a git repository", async () => { + const dir = await mkdtemp(join(tmpdir(), "deepagent-code-git-")) + try { + await writeFile(join(dir, "lonely.txt"), "x") + const res = await isTracked(dir, "lonely.txt") + expect(res.ok).toBe(true) + if (res.ok) expect(res.tracked).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("fileLog", () => { + test("returns commits touching the file in reverse-chronological order", async () => { + await withRepo(async (repo) => { + await writeFile(join(repo, "doc.md"), "first") + await commit(repo, "create doc") + await writeFile(join(repo, "doc.md"), "second") + await commit(repo, "update doc") + + const res = await fileLog(repo, "doc.md") + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.entries.length).toBe(2) + // most recent first + expect(res.entries[0].subject).toBe("update doc") + expect(res.entries[1].subject).toBe("create doc") + // each entry has hash/author/date populated + expect(res.entries[0].hash).toMatch(/^[0-9a-f]{7,}$/) + expect(res.entries[0].author).toBe("Test User") + expect(res.entries[0].date).toBeTruthy() + }) + }) + + test("returns an empty list for a file with no history", async () => { + await withRepo(async (repo) => { + await writeFile(join(repo, "seed.txt"), "x") + await commit(repo, "seed") + await writeFile(join(repo, "fresh.txt"), "never committed") + + const res = await fileLog(repo, "fresh.txt") + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.entries).toEqual([]) + }) + }) + + test("follows renames across commits", async () => { + await withRepo(async (repo) => { + await writeFile(join(repo, "old.md"), "content") + await commit(repo, "add old") + // rename via git mv so --follow can trace it + await exec("git", ["-C", repo, "mv", "old.md", "new.md"]) + await commit(repo, "rename to new") + + const res = await fileLog(repo, "new.md") + expect(res.ok).toBe(true) + if (!res.ok) return + // --follow surfaces history from before the rename + expect(res.entries.length).toBeGreaterThanOrEqual(2) + expect(res.entries.some((e) => e.subject === "add old")).toBe(true) + expect(res.entries.some((e) => e.subject === "rename to new")).toBe(true) + }) + }) +}) diff --git a/packages/desktop/src/main/git.ts b/packages/desktop/src/main/git.ts new file mode 100644 index 00000000..dbf90c70 --- /dev/null +++ b/packages/desktop/src/main/git.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +export type GitLogEntry = { + hash: string + author: string + date: string + subject: string +} + +export type GitLogResult = { ok: true; entries: GitLogEntry[] } | { ok: false; error: string } + +export type GitTrackedResult = { ok: true; tracked: boolean } | { ok: false; error: string } + +/** Whether a file is tracked by git in the given working directory. */ +export async function isTracked(workDir: string, relPath: string): Promise { + try { + await execFileAsync("git", ["-C", workDir, "ls-files", "--error-unmatch", "--", relPath]) + return { ok: true, tracked: true } + } catch (error) { + // Non-zero exit means either not a repo or not tracked. Distinguish from unexpected errors. + const message = error instanceof Error ? error.message : String(error) + if (isGitMissingOrNotTracked(message)) return { ok: true, tracked: false } + return { ok: false, error: message } + } +} + +/** Fetch the commit history for a single file (follows renames). */ +export async function fileLog(workDir: string, relPath: string): Promise { + try { + // \x1f separates fields, \x1e separates records. %ad keeps an ISO-ish date with timezone. + const { stdout } = await execFileAsync( + "git", + [ + "-C", + workDir, + "log", + "--follow", + "--no-patch", + "--pretty=format:%H%x1f%an%x1f%ad%x1f%s%x1e", + "--date=iso", + "--", + relPath, + ], + { maxBuffer: 16 * 1024 * 1024 }, + ) + + const entries: GitLogEntry[] = [] + const trimmed = stdout.replace(/\x1e$/, "") + if (trimmed) { + for (const record of trimmed.split("\x1e")) { + const [hash, author, date, subject] = record.split("\x1f") + if (!hash) continue + entries.push({ hash, author: author ?? "", date: date ?? "", subject: subject ?? "" }) + } + } + return { ok: true, entries } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (isGitMissingOrNotTracked(message)) return { ok: true, entries: [] } + return { ok: false, error: message } + } +} + +function isGitMissingOrNotTracked(message: string): boolean { + return ( + // `git` binary not installed or not on PATH (e.g. a minimal Windows install). Treat as + // "not a git repo" so the UI degrades to an empty timeline instead of a hard error. + message.includes("spawn git ENOENT") || + message.includes("not a git repository") || + message.includes("did not match any file") || + message.includes("fatal: not a git") || + message.includes("unknown revision") || + message.includes("Not a git repository") + ) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 6fef738e..ada580f4 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -33,11 +33,15 @@ import { setRelaunchHandler, setBackgroundColor, setDockIcon, + setCloseToTrayEnabled, + setIsQuitting, } from "./windows" import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" +import { startPowerSaveBlocker, stopPowerSaveBlocker } from "./power" +import { createTray, destroyTray } from "./tray" const APP_NAMES: Record = { dev: "DeepAgent Code Dev", @@ -207,11 +211,14 @@ const main = Effect.gen(function* () { }) app.on("before-quit", () => { + setIsQuitting(true) void stopSidecars() }) app.on("will-quit", () => { void stopSidecars() + stopPowerSaveBlocker() + destroyTray() }) app.on("child-process-gone", (_event, details) => { @@ -222,6 +229,15 @@ const main = Effect.gen(function* () { writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error") }) + // macOS: re-show the window when the user clicks the Dock icon after it was hidden to the tray. + // Also serves as a recovery path on any platform if the window is hidden without a visible tray. + app.on("activate", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) mainWindow.show() + mainWindow.focus() + } + }) + setRelaunchHandler(() => { relaunch() }) @@ -285,6 +301,14 @@ const main = Effect.gen(function* () { }) } + // Keep the app running (prevent idle sleep/hibernate) while it is active. The tray icon enables + // close-to-tray: closing the window hides it to the tray with a right-click “Quit” to fully exit. + // On platforms without tray support (e.g. Linux GNOME default), tray creation fails silently and + // close-to-tray stays disabled so closing the window quits normally — avoiding a stranded window. + startPowerSaveBlocker() + const trayCreated = createTray(() => mainWindow) + setCloseToTrayEnabled(trayCreated) + void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 8f38cd05..9d8b4ce4 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -8,6 +8,8 @@ import type { DesktopMenuAction } from "@deepagent-code/app/desktop-menu" import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" +import { archivePath, copyPath, extractPath, guardFileOpCall, movePath, removePath, renamePath, type FileOpResult } from "./file-ops" +import { fileLog, isTracked } from "./git" import { getStore } from "./store" import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" import { browserView } from "./browser-view" @@ -255,6 +257,43 @@ export function registerIpcHandlers(deps: Deps) { relaunch: deps.relaunch, }) }) + + // ── File-tree context-menu operations ─────────────────────────────────── + // These act directly on the local filesystem (workspace files) and do not route through the + // sidecar server. The renderer passes the workspace root as the first argument; every path is + // checked to stay inside it so the bridge cannot touch files outside the workspace. + const fileOp = + (run: (root: string, ...args: Args) => Promise) => + (_event: IpcMainInvokeEvent, root: string, ...args: Args): Promise => { + const guard = guardFileOpCall(root, args.filter((a): a is string => typeof a === "string")) + return guard ? Promise.resolve(guard) : run(root, ...args) + } + + ipcMain.handle("file-ops-copy", fileOp((root, source: string, destDir: string) => copyPath(source, destDir))) + ipcMain.handle("file-ops-move", fileOp((root, source: string, destDir: string) => movePath(source, destDir))) + ipcMain.handle("file-ops-remove", fileOp((root, target: string) => removePath(target))) + // rename's nextName is a bare filename, not a workspace path — it must NOT be passed to + // guardFileOpCall (which resolves it against cwd, not root). The desktop main process sets + // cwd to homedir, so a bare name would always resolve outside root and be wrongly rejected. + // Only `target` is guarded; renamePath itself validates the name (empty, separators, illegal + // chars, collisions). See file-ops.test.ts "rename IPC guard" for the locked-in contract. + ipcMain.handle( + "file-ops-rename", + (_event: IpcMainInvokeEvent, root: string, target: string, nextName: string) => { + const guard = guardFileOpCall(root, [target]) + return guard ? Promise.resolve(guard) : renamePath(target, nextName) + }, + ) + ipcMain.handle("file-ops-archive", fileOp((root, target: string) => archivePath(target))) + ipcMain.handle("file-ops-extract", fileOp((root, zipPath: string) => extractPath(zipPath))) + + // ── Git file timeline ─────────────────────────────────────────────────── + ipcMain.handle("git-is-tracked", (_event: IpcMainInvokeEvent, workDir: string, relPath: string) => + isTracked(workDir, relPath), + ) + ipcMain.handle("git-file-log", (_event: IpcMainInvokeEvent, workDir: string, relPath: string) => + fileLog(workDir, relPath), + ) } export function sendMenuCommand(win: BrowserWindow, id: string) { diff --git a/packages/desktop/src/main/power.ts b/packages/desktop/src/main/power.ts new file mode 100644 index 00000000..aa8c6121 --- /dev/null +++ b/packages/desktop/src/main/power.ts @@ -0,0 +1,16 @@ +import { powerSaveBlocker } from "electron" + +// prevent-app-suspension keeps the system/app from idling to sleep while allowing the display to +// turn off (go dark) and the lid to close — i.e. it blocks idle sleep & hibernate, not screen blank. +let blockerId: number | null = null + +export function startPowerSaveBlocker(): void { + if (blockerId !== null) return + blockerId = powerSaveBlocker.start("prevent-app-suspension") +} + +export function stopPowerSaveBlocker(): void { + if (blockerId === null) return + if (powerSaveBlocker.isStarted(blockerId)) powerSaveBlocker.stop(blockerId) + blockerId = null +} diff --git a/packages/desktop/src/main/tray.ts b/packages/desktop/src/main/tray.ts new file mode 100644 index 00000000..38753564 --- /dev/null +++ b/packages/desktop/src/main/tray.ts @@ -0,0 +1,68 @@ +import { app, BrowserWindow, Menu, Tray, nativeImage } from "electron" +import { join } from "node:path" +import { write as writeLog } from "./logging" +import { iconsDir, setIsQuitting } from "./windows" + +let tray: Tray | null = null + +/** + * Create the system tray icon. Returns true on success. + * + * Tray support is optional by platform: Linux GNOME (default) ships no StatusNotifierItem/AppIndicator + * host, and `new Tray()` may throw or produce a no-op tray there. Callers must only enable close-to-tray + * behavior when this returns true, otherwise a hidden window would be unrecoverable. + */ +export function createTray(getMainWindow: () => BrowserWindow | null): boolean { + if (tray) return true + + const source = nativeImage.createFromPath(join(iconsDir(), "32x32.png")) + if (source.isEmpty()) { + // The tray icon failed to load (missing resource or decode error). Creating a Tray from an + // empty image can still succeed on some platforms (notably Linux), which would then enable + // close-to-tray with an invisible icon — hiding the window with no way to recover it. + writeLog("tray", "tray icon image is empty, skipping tray creation", {}, "warn") + return false + } + const icon = source.resize({ width: 22, height: 22 }) + + try { + tray = new Tray(icon) + } catch (error) { + writeLog("tray", "failed to create tray", { error }, "warn") + tray = null + return false + } + + tray.setToolTip("DeepAgent Code") + + const menu = Menu.buildFromTemplate([ + { label: "Show DeepAgent Code", click: () => showMainWindow(getMainWindow) }, + { type: "separator" }, + { + label: "Quit", + click: () => { + setIsQuitting(true) + app.quit() + }, + }, + ]) + tray.setContextMenu(menu) + tray.on("click", () => showMainWindow(getMainWindow)) + return true +} + +function showMainWindow(getMainWindow: () => BrowserWindow | null): void { + const win = getMainWindow() + if (!win || win.isDestroyed()) return + if (win.isVisible()) { + win.focus() + return + } + win.show() + win.focus() +} + +export function destroyTray(): void { + tray?.destroy() + tray = null +} diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index eaac95e7..e95bac05 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -10,6 +10,7 @@ import type { TitlebarTheme } from "../preload/types" import { exportDebugLogs, write as writeLog } from "./logging" import { getStore } from "./store" import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys" +import { shouldHideOnClose } from "./close-to-tray" import { createUnresponsiveSampler } from "./unresponsive" const root = dirname(fileURLToPath(import.meta.url)) @@ -43,6 +44,24 @@ let relaunchHandler = () => { app.relaunch() app.exit(0) } +// When false, closing the main window hides it to the tray instead of quitting. +let isQuitting = false +// Close-to-tray is only armed when a system tray was successfully created. On platforms without tray +// support (e.g. GNOME), closing the window must quit normally or the window would be hidden with no +// way to recover it. +let closeToTrayEnabled = false + +export function getIsQuitting() { + return isQuitting +} + +export function setIsQuitting(value: boolean) { + isQuitting = value +} + +export function setCloseToTrayEnabled(value: boolean) { + closeToTrayEnabled = value +} const titlebarThemes = new WeakMap>() const pinchZoomEnabled = new WeakMap() const titlebarHeight = 40 @@ -60,7 +79,7 @@ export function getBackgroundColor(): string | undefined { return backgroundColor } -function iconsDir() { +export function iconsDir() { return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons") } @@ -173,6 +192,16 @@ export function createMainWindow() { loadWindow(win, "index.html") wireZoom(win) + win.on("close", (event) => { + // Close-to-tray: hide instead of quitting, but only when a tray is available to recover the + // window. Without a tray (e.g. Linux GNOME), closing must quit normally or the window would be + // stranded. An explicit quit (tray Quit / Cmd+Q / before-quit) sets isQuitting to bypass this. + if (shouldHideOnClose({ isQuitting: getIsQuitting(), trayAvailable: closeToTrayEnabled })) { + event.preventDefault() + win.hide() + } + }) + win.once("ready-to-show", () => { win.show() }) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 03139411..1c83db5a 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,7 +1,6 @@ import { contextBridge, ipcRenderer } from "electron" import type { ElectronAPI, WslServersEvent, BrowserState } from "./types" import type { UpdaterState } from "@deepagent-code/app/updater" - const updaterCallbacks = new Set<(state: UpdaterState) => void>() let updaterState: UpdaterState | undefined let updaterSubscription: Promise | undefined @@ -134,6 +133,21 @@ const api: ElectronAPI = { exportDebugLogs: (options?: { windowMs?: number; pick?: boolean }) => ipcRenderer.invoke("export-debug-logs", options), recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), + fileOps: { + copy: (root: string, source: string, destDir: string) => + ipcRenderer.invoke("file-ops-copy", root, source, destDir), + move: (root: string, source: string, destDir: string) => + ipcRenderer.invoke("file-ops-move", root, source, destDir), + remove: (root: string, target: string) => ipcRenderer.invoke("file-ops-remove", root, target), + rename: (root: string, target: string, nextName: string) => + ipcRenderer.invoke("file-ops-rename", root, target, nextName), + archive: (root: string, target: string) => ipcRenderer.invoke("file-ops-archive", root, target), + extract: (root: string, zipPath: string) => ipcRenderer.invoke("file-ops-extract", root, zipPath), + }, + git: { + isTracked: (workDir, relPath) => ipcRenderer.invoke("git-is-tracked", workDir, relPath), + fileLog: (workDir, relPath) => ipcRenderer.invoke("git-file-log", workDir, relPath), + }, } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 9375bcc2..3d882a6b 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -54,6 +54,22 @@ export type BrowserAPI = { onState: (cb: (state: BrowserState) => void) => () => void } +export type FileOpResult = { ok: boolean; error?: string; path?: string } +export type FileOpsAPI = { + copy: (root: string, source: string, destDir: string) => Promise + move: (root: string, source: string, destDir: string) => Promise + remove: (root: string, target: string) => Promise + rename: (root: string, target: string, nextName: string) => Promise + archive: (root: string, target: string) => Promise + extract: (root: string, zipPath: string) => Promise +} + +export type GitLogEntry = { hash: string; author: string; date: string; subject: string } +export type GitAPI = { + isTracked: (workDir: string, relPath: string) => Promise<{ ok: boolean; tracked: boolean; error?: string }> + fileLog: (workDir: string, relPath: string) => Promise<{ ok: boolean; entries: GitLogEntry[]; error?: string }> +} + export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise @@ -113,4 +129,6 @@ export type ElectronAPI = { setBackgroundColor: (color: string) => Promise exportDebugLogs: (options?: { windowMs?: number; pick?: boolean }) => Promise recordFatalRendererError: (error: FatalRendererError) => Promise + fileOps: FileOpsAPI + git: GitAPI } diff --git a/turbo.json b/turbo.json index 9368fbfc..b409e9f1 100644 --- a/turbo.json +++ b/turbo.json @@ -39,6 +39,13 @@ "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] + }, + "@deepagent-code/desktop#test": { + "outputs": [] + }, + "@deepagent-code/desktop#test:ci": { + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } } From 29943c44fd82ab064f9a7f68f540cc2274aad1f3 Mon Sep 17 00:00:00 2001 From: thomas-yanga Date: Fri, 24 Jul 2026 15:18:30 +0800 Subject: [PATCH 2/2] test(app): stub Kobalte UI leaves added by desktop file-management The desktop file-management branch added ContextMenu, InlineInput, GitTimelineDialog, and toast/language imports to file-tree.tsx. Their Kobalte-backed UI leaves (context-menu, dialog, toast, v2/toast-v2) call solid-js/web template() at module top level; under bun:test solid-js resolves to its server build and template() throws notSup(), failing the file-tree.test.ts load. Stub those leaves the way collapsible/tooltip already are so the pure-function tests load the module without the client-only render path. --- packages/app/src/components/file-tree.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 9879f9a5..5059c482 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -29,6 +29,23 @@ beforeAll(async () => { mock.module("@deepagent-code/ui/file-icon", () => ({ FileIcon: () => null })) mock.module("@deepagent-code/ui/icon", () => ({ Icon: () => null })) mock.module("@deepagent-code/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) + // The desktop file-management branch added ContextMenu, InlineInput, GitTimelineDialog, and + // toast/language helpers to this module. Their Kobalte-backed UI leaves call solid-js/web + // `template()` at module top level; under bun:test solid-js resolves to its server build and + // those calls throw notSup(), so stub the leaves the way collapsible/tooltip are stubbed above. + mock.module("@deepagent-code/ui/context-menu", () => ({ + ContextMenu: Object.assign(() => null, { + Trigger: (props: { children?: unknown }) => props.children, + Portal: (props: { children?: unknown }) => props.children, + Content: (props: { children?: unknown }) => props.children, + }), + })) + mock.module("@deepagent-code/ui/dialog", () => ({ + Dialog: Object.assign(() => null, { Content: (props: { children?: unknown }) => props.children }), + })) + mock.module("@deepagent-code/ui/inline-input", () => ({ InlineInput: () => null })) + mock.module("@deepagent-code/ui/toast", () => ({ showToast: () => undefined, Toast: () => null })) + mock.module("@deepagent-code/ui/v2/toast-v2", () => ({ showToastV2: () => undefined, ToastV2: () => null })) const mod = await import("./file-tree") shouldListRoot = mod.shouldListRoot shouldListExpanded = mod.shouldListExpanded