feat: Discard all, claude-style image pastes, large local drops, per-account / list

- Changes panel: **Discard all** beside Commit discards every non-conflicted
  file in one `discard`, after a dialog naming how many untracked files go.
- Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as
  `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local
  browser's copied `file://` paths and text-drag file lists paste by path.
- `POST /api/run/drop-file`: a loopback browser outside server mode may drop
  up to 2 GB, streamed to disk; remote stays 20 MB.
- cc-config skills/commands/plugins read the USER scope of the account the
  pane runs as (S3 member home by `cwd`), never another user's or the host's
  for a non-admin without an account; the run form passes its cwd to plugins.
This commit is contained in:
2026-10-21 14:27:36 +07:00
parent 0547488f13
commit aee4fc190e
16 changed files with 380 additions and 65 deletions
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
@@ -2756,6 +2777,17 @@ export function LaneChangesPanel({
>
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button>
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
</button>
</div>
</div>
)}
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
confirm: true,
})
);
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
+1 -1
View File
@@ -308,7 +308,7 @@ export function PromptEditor({
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
]).then(([c, sk, pl]) => {
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
// installed at both user and project scope is listed once.
+43 -14
View File
@@ -17,9 +17,10 @@
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent; a browser on the
* dashboard's own machine pastes a file manager's files by their own paths
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -66,7 +67,8 @@ import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import {
claudeMention,
claudePastes,
droppedFilePaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
@@ -153,6 +155,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
const hasText = (e: DragEvent) =>
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
/** The `file://` paths a paste carries as text — a file manager's copy — or
* null: its uri-list, else its plain text when every line is one. */
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
const text = dt?.getData("text/plain") ?? "";
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
if (!/^file:\/\//.test(text.trim())) return null;
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
}
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
@@ -168,8 +179,9 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
* as an `@` mention (`claudeMention`), any other one its plain path. */
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
* as its bare path and any other file as an `@` mention (`claudePastes`),
* any other tool its plain path. */
tool?: string;
}
@@ -241,8 +253,10 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault();
dragDepth.current = 0;
setDrop(null);
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " ");
if (local) pastePaths(local);
else if (text) termRef.current?.paste(text + " ");
termRef.current?.focus();
return;
}
@@ -262,6 +276,14 @@ export const TerminalView = memo(function TerminalView({
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
// A file manager's copied files reach a browser as `file://` text only.
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
if (local) {
e.preventDefault();
e.stopPropagation();
pastePaths(local);
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
@@ -269,6 +291,19 @@ export const TerminalView = memo(function TerminalView({
else termRef.current?.input("\x16", true);
};
/** Pastes files' paths as a local terminal hands them over — paste(), not
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
* rather than typed keystrokes. A `claude` pane gets its images as bare
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
* (`claudePastes`). */
const pastePaths = (paths: string[]) => {
const term = termRef.current;
if (!term) return;
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
term.paste(text + " ");
term.focus();
};
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
@@ -286,13 +321,7 @@ export const TerminalView = memo(function TerminalView({
return [];
});
}
if (paths.length) {
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(paths.map(asText).join(" ") + " ");
termRef.current?.focus();
}
if (paths.length) pastePaths(paths);
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
+3
View File
@@ -531,6 +531,9 @@
"changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push",
"changes.discardAll": "Discard all",
"changes.discardAllTitle": "Discard all changes?",
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
"changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit",
+3
View File
@@ -531,6 +531,9 @@
"changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push",
"changes.discardAll": "Bỏ tất cả",
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
"changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit",
@@ -0,0 +1,20 @@
/**
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { claudePastes } from "../promptAttach";
describe("claudePastes", () => {
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
expect(
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
});
it("keeps a Windows or spaced image path a mention", () => {
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
'@C:/u/drops/a.png @"/home/u/my shot.png"',
]);
});
});
+2 -1
View File
@@ -2210,7 +2210,8 @@ export const api = {
* each one contributes (skills/agents/commands/hooks counts).
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
*/
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
plugins: (cwd?: string) =>
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
/**
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
+16
View File
@@ -169,6 +169,22 @@ export function claudeMention(path: string): string {
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
}
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
* paste per entry, as a local terminal hands them over: the images as their
* bare paths, each a paste of its own — the shape Claude Code turns into
* `[Image #n]`, as it does a native drop — and every other file as an `@`
* mention, together in one. A Windows path, or
* one with a space, stays a mention — the bare path stayed plain text there
* (see `claudeMention`). */
export function claudePastes(paths: string[]): string[] {
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
const images = paths.filter(bare);
const others = paths.filter((p) => !bare(p)).map(claudeMention);
return [...images, others.join(" ")].filter(Boolean);
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;