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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -76,10 +77,7 @@ declare global {
__DEEPAGENT_CODE__?: {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: (options?: { windowMs?: number; pick?: boolean }) => Promise<string | null>
}
api?: DesktopApi
}
}

Expand Down
135 changes: 135 additions & 0 deletions packages/app/src/components/file-tree-context-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<Clip | null>(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 (
<>
<ContextMenu.Item onSelect={() => copyText(props.node.path)}>
<ContextMenu.ItemLabel>{language.t("fileTree.copyRelativePath")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => copyText(props.node.absolute)}>
<ContextMenu.ItemLabel>{language.t("fileTree.copyAbsolutePath")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => markClip("copy")} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("fileTree.copy")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => markClip("cut")} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("fileTree.cut")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={canPaste({ nodeType: props.node.type, hasClip: Boolean(clip()), localFs: localFs() })}>
<ContextMenu.Item onSelect={paste}>
<ContextMenu.ItemLabel>{language.t("fileTree.paste")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={remove} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("common.delete")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => props.onRename()} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("common.rename")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={archive} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("fileTree.archive")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={canExtract({ nodeType: props.node.type, name: props.node.name })}>
<ContextMenu.Item onSelect={extract} disabled={!localFs()}>
<ContextMenu.ItemLabel>{language.t("fileTree.extract")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<Show when={canOpenTimeline({ nodeType: props.node.type, localFs: localFs() })}>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => props.onOpenTimeline(props.node)}>
<ContextMenu.ItemLabel>{language.t("fileTree.openTimeline")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
</>
)
}
80 changes: 80 additions & 0 deletions packages/app/src/components/file-tree-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
31 changes: 31 additions & 0 deletions packages/app/src/components/file-tree-menu.ts
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions packages/app/src/components/file-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading