mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu * fix(app): preserve changed-file row behavior * feat(app): add changed files to open chats * feat(app): share file actions across explorer and changes * feat(app): attach workspace files to focused chat * feat(app): drag workspace files into chat * refactor(app): use file actions menu for changed files * fix(app): open changed-file actions on right click * test(app): cover direct file attachment flow * fix(app): enable add to chat on native * fix(app): align workspace file pane rows * refactor(app): simplify workspace file attachments * fix(app): preserve uploaded file draft attachments --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
@@ -12,6 +12,10 @@ interface DirtyWorkspace {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
@@ -218,6 +222,25 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
await page.getByTestId("diff-file-1-actions").click();
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -250,6 +273,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
const changesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "changes-options-menu"),
|
||||
readSvgRight(page, "diff-file-0-actions"),
|
||||
]);
|
||||
expectAligned(changesRightRail);
|
||||
|
||||
const [folderStat, fileStat] = await Promise.all([
|
||||
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(folderStat).not.toBeNull();
|
||||
expect(fileStat).not.toBeNull();
|
||||
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
|
||||
const filesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "files-refresh"),
|
||||
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||
]);
|
||||
expectAligned(filesRightRail);
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(sortLabel).not.toBeNull();
|
||||
expect(firstRowIcon).not.toBeNull();
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -399,10 +469,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
async function createWorkspaceWithMountedTabDiff(
|
||||
options: WorkspaceFixtureOptions = {},
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -412,6 +486,9 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
@@ -436,6 +513,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not measure ${testID}`);
|
||||
}
|
||||
return box.x + box.width;
|
||||
}
|
||||
|
||||
function expectAligned(values: number[]): void {
|
||||
const [first, ...rest] = values;
|
||||
for (const value of rest) {
|
||||
expect(value).toBeCloseTo(first, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
|
||||
@@ -92,9 +92,20 @@ export interface ChatHistoryContextAttachment {
|
||||
|
||||
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
||||
|
||||
export type WorkspaceFileSelection =
|
||||
| { kind: "whole_file" }
|
||||
| { kind: "line_range"; startLine: number; endLine: number };
|
||||
|
||||
export interface WorkspaceFileComposerAttachment {
|
||||
kind: "workspace_file";
|
||||
path: string;
|
||||
selection: WorkspaceFileSelection;
|
||||
}
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| WorkspaceFileComposerAttachment
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource(
|
||||
_input: WorkspaceFileDragSourceInput,
|
||||
): RefCallback<View> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState, type RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource({
|
||||
enabled,
|
||||
disabled = false,
|
||||
serverId,
|
||||
workspaceId,
|
||||
path,
|
||||
selection,
|
||||
}: WorkspaceFileDragSourceInput): RefCallback<View> {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const dragSourceRef = useCallback((node: View | null) => {
|
||||
setElement(node as unknown as HTMLElement | null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element || !enabled || disabled || !serverId || !workspaceId) {
|
||||
if (element) {
|
||||
element.draggable = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceServerId = serverId;
|
||||
const sourceWorkspaceId = workspaceId;
|
||||
element.draggable = true;
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(
|
||||
WORKSPACE_FILE_DRAG_MIME,
|
||||
serializeWorkspaceFileDragPayload({
|
||||
version: 1,
|
||||
serverId: sourceServerId,
|
||||
workspaceId: sourceWorkspaceId,
|
||||
attachment: createWorkspaceFileAttachment({ path, selection }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
element.addEventListener("dragstart", handleDragStart);
|
||||
return () => {
|
||||
element.draggable = false;
|
||||
element.removeEventListener("dragstart", handleDragStart);
|
||||
};
|
||||
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
|
||||
|
||||
return dragSourceRef;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WorkspaceFileSelection } from "./types";
|
||||
|
||||
export interface WorkspaceFileDragSourceInput {
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string;
|
||||
workspaceId: string | null | undefined;
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
62
packages/app/src/attachments/workspace-file-drag.test.ts
Normal file
62
packages/app/src/attachments/workspace-file-drag.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||
import {
|
||||
parseWorkspaceFileDragPayload,
|
||||
resolveWorkspaceFileDrop,
|
||||
serializeWorkspaceFileDragPayload,
|
||||
type WorkspaceFileDragPayload,
|
||||
} from "./workspace-file-drag";
|
||||
|
||||
function payload(): WorkspaceFileDragPayload {
|
||||
return {
|
||||
version: 1,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
attachment: createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace file drag payload", () => {
|
||||
it("round-trips workspace identity and future line selections", () => {
|
||||
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
|
||||
payload(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed and invalid payloads", () => {
|
||||
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
|
||||
expect(
|
||||
parseWorkspaceFileDragPayload(
|
||||
JSON.stringify({
|
||||
...payload(),
|
||||
attachment: {
|
||||
kind: "workspace_file",
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 24, endLine: 12 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts drops only within the originating server and workspace", () => {
|
||||
const dragged = payload();
|
||||
expect(
|
||||
resolveWorkspaceFileDrop({
|
||||
payload: dragged,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual(dragged.attachment);
|
||||
expect(
|
||||
resolveWorkspaceFileDrop({
|
||||
payload: dragged,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-2",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
55
packages/app/src/attachments/workspace-file-drag.ts
Normal file
55
packages/app/src/attachments/workspace-file-drag.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { WorkspaceFileComposerAttachment } from "./types";
|
||||
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
|
||||
|
||||
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
|
||||
|
||||
export interface WorkspaceFileDragPayload {
|
||||
version: 1;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
}
|
||||
|
||||
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(serialized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
typeof record.serverId !== "string" ||
|
||||
record.serverId.length === 0 ||
|
||||
typeof record.workspaceId !== "string" ||
|
||||
record.workspaceId.length === 0 ||
|
||||
!isWorkspaceFileComposerAttachment(record.attachment)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
serverId: record.serverId,
|
||||
workspaceId: record.workspaceId,
|
||||
attachment: record.attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceFileDrop(input: {
|
||||
payload: WorkspaceFileDragPayload;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceFileComposerAttachment | null {
|
||||
return input.payload.serverId === input.serverId &&
|
||||
input.payload.workspaceId === input.workspaceId
|
||||
? input.payload.attachment
|
||||
: null;
|
||||
}
|
||||
71
packages/app/src/attachments/workspace-file.test.ts
Normal file
71
packages/app/src/attachments/workspace-file.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
createWorkspaceFileAttachment,
|
||||
getWorkspaceFileAttachmentKey,
|
||||
getWorkspaceFileAttachmentSubtitle,
|
||||
workspaceFileAttachmentToAgentAttachment,
|
||||
} from "./workspace-file";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
|
||||
describe("workspace file attachments", () => {
|
||||
it("models whole files and line ranges as distinct selections", () => {
|
||||
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||
const lineRange = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
});
|
||||
|
||||
expect(wholeFile).toEqual({
|
||||
kind: "workspace_file",
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "whole_file" },
|
||||
});
|
||||
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
|
||||
getWorkspaceFileAttachmentKey(lineRange),
|
||||
);
|
||||
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
|
||||
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
|
||||
});
|
||||
|
||||
it("deduplicates only identical paths and selections", () => {
|
||||
const image = {
|
||||
kind: "image" as const,
|
||||
metadata: {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb" as const,
|
||||
storageKey: "image-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
};
|
||||
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||
const range = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 1, endLine: 5 },
|
||||
});
|
||||
const current: UserComposerAttachment[] = [image, wholeFile];
|
||||
|
||||
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
|
||||
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
|
||||
});
|
||||
|
||||
it("submits a path reference without uploading or inserting prompt text", () => {
|
||||
const attachment = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
});
|
||||
|
||||
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "app.ts",
|
||||
text: "Workspace file: src/app.ts\nLines: 12-24",
|
||||
});
|
||||
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
|
||||
images: [],
|
||||
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/app/src/attachments/workspace-file.ts
Normal file
106
packages/app/src/attachments/workspace-file.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceFileSelection,
|
||||
} from "./types";
|
||||
|
||||
interface CreateWorkspaceFileAttachmentInput {
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.trim().replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
export function isWorkspaceFileComposerAttachment(
|
||||
value: unknown,
|
||||
): value is WorkspaceFileComposerAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.kind !== "workspace_file" ||
|
||||
typeof record.path !== "string" ||
|
||||
record.path.trim().length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const selection = record.selection;
|
||||
if (!selection || typeof selection !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { kind, startLine, endLine } = selection as Record<string, unknown>;
|
||||
if (kind === "whole_file") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
kind === "line_range" &&
|
||||
typeof startLine === "number" &&
|
||||
Number.isInteger(startLine) &&
|
||||
typeof endLine === "number" &&
|
||||
Number.isInteger(endLine) &&
|
||||
startLine > 0 &&
|
||||
endLine >= startLine
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkspaceFileAttachment({
|
||||
path,
|
||||
selection = { kind: "whole_file" },
|
||||
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
|
||||
return {
|
||||
kind: "workspace_file",
|
||||
path: normalizePath(path),
|
||||
selection,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
|
||||
const selection = attachment.selection;
|
||||
const selectionKey =
|
||||
selection.kind === "whole_file"
|
||||
? selection.kind
|
||||
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
|
||||
return `${normalizePath(attachment.path)}:${selectionKey}`;
|
||||
}
|
||||
|
||||
export function appendWorkspaceFileAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): UserComposerAttachment[] {
|
||||
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
|
||||
const alreadyAttached = current.some(
|
||||
(candidate) =>
|
||||
candidate.kind === "workspace_file" &&
|
||||
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
|
||||
);
|
||||
return alreadyAttached ? current : [...current, attachment];
|
||||
}
|
||||
|
||||
export function workspaceFileAttachmentToAgentAttachment(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): Extract<AgentAttachment, { type: "text" }> {
|
||||
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||
const lines =
|
||||
attachment.selection.kind === "line_range"
|
||||
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
|
||||
: "";
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: fileName,
|
||||
text: `Workspace file: ${attachment.path}${lines}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentSubtitle(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): string {
|
||||
if (attachment.selection.kind === "whole_file") {
|
||||
return attachment.path;
|
||||
}
|
||||
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} testID={testID}>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
|
||||
@@ -36,6 +36,13 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
@@ -386,15 +393,16 @@ function ExplorerSidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -415,6 +423,83 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||
* to chat" action that attaches the file to the focused chat's composer.
|
||||
* Available only when a workspace with a focused chat is available.
|
||||
*/
|
||||
function useAddFileToChat({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||
const workspaceKey = workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||
[serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
void useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: Pick<
|
||||
SidebarContentProps,
|
||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||
>) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
|
||||
185
packages/app/src/components/file-actions-menu.tsx
Normal file
185
packages/app/src/components/file-actions-menu.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
|
||||
/** Width occupied by a file action trigger, including its visual padding. */
|
||||
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
|
||||
|
||||
interface FileAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onSelect: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface FileActionsMenuProps {
|
||||
fileKind: "file" | "directory";
|
||||
fileExists?: boolean;
|
||||
onOpenFile?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onDownload?: () => void;
|
||||
onAddToChat?: () => void;
|
||||
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||
header?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
hitSlop?: number;
|
||||
accessibilityLabel: string;
|
||||
testIDPrefix?: string;
|
||||
}
|
||||
|
||||
// The menu lives inside pressable rows (diff header, explorer entry); stop the
|
||||
// press so opening it doesn't also trigger the row.
|
||||
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
|
||||
function triggerStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
|
||||
* git diff pane so both surfaces share action availability, ordering, and chrome.
|
||||
*/
|
||||
export function FileActionsMenu({
|
||||
fileKind,
|
||||
fileExists = true,
|
||||
onOpenFile,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onAddToChat,
|
||||
header,
|
||||
open,
|
||||
onOpenChange,
|
||||
hitSlop = 12,
|
||||
accessibilityLabel,
|
||||
testIDPrefix,
|
||||
}: FileActionsMenuProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const actions = useMemo<FileAction[]>(() => {
|
||||
const availableFile = fileKind === "file" && fileExists;
|
||||
const next: FileAction[] = [];
|
||||
if (availableFile && onOpenFile) {
|
||||
next.push({
|
||||
key: "open-file",
|
||||
label: t("workspace.fileActions.openFile"),
|
||||
icon: FileText,
|
||||
onSelect: onOpenFile,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
||||
});
|
||||
}
|
||||
if (onCopyPath) {
|
||||
next.push({
|
||||
key: "copy-path",
|
||||
label: t("workspace.fileActions.copyPath"),
|
||||
icon: Copy,
|
||||
onSelect: onCopyPath,
|
||||
});
|
||||
}
|
||||
if (availableFile && onDownload) {
|
||||
next.push({
|
||||
key: "download",
|
||||
label: t("workspace.fileActions.download"),
|
||||
icon: Download,
|
||||
onSelect: onDownload,
|
||||
});
|
||||
}
|
||||
if (availableFile && onAddToChat) {
|
||||
next.push({
|
||||
key: "add-to-chat",
|
||||
label: t("workspace.fileActions.addToChat"),
|
||||
icon: MessageSquarePlus,
|
||||
onSelect: onAddToChat,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={hitSlop}
|
||||
onPressIn={stopTriggerPropagation}
|
||||
style={triggerStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
|
||||
>
|
||||
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{header ? (
|
||||
<>
|
||||
{header}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{actions.map((action) => (
|
||||
<FileActionMenuItem key={action.key} action={action} />
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||
const Icon = action.icon;
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
||||
[ThemedIcon],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
// The hover box comes from padding, but an equal negative vertical margin
|
||||
// cancels its height contribution so the trigger overlaps the row's natural
|
||||
// line height instead of growing it. The comfortable tap target is `hitSlop`,
|
||||
// never padding.
|
||||
padding: theme.spacing[1],
|
||||
width: FILE_ACTIONS_MENU_WIDTH,
|
||||
marginVertical: -theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
triggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
} from "@/attachments/file-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||
import {
|
||||
parseWorkspaceFileDragPayload,
|
||||
WORKSPACE_FILE_DRAG_MIME,
|
||||
} from "@/attachments/workspace-file-drag";
|
||||
|
||||
type DesktopDragDropPayload =
|
||||
| { type: "enter"; paths: string[] }
|
||||
@@ -185,7 +189,11 @@ export function useDropListeners({
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
if (suppressed.value || !hasSink.value) return;
|
||||
const types = new Set(e.dataTransfer?.types ?? []);
|
||||
const acceptsWorkspaceFile =
|
||||
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||
if (types.has("Files") || acceptsWorkspaceFile) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +205,11 @@ export function useDropListeners({
|
||||
if (!e.dataTransfer) return;
|
||||
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
const types = new Set(e.dataTransfer.types);
|
||||
const acceptsWorkspaceFile =
|
||||
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
|
||||
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||
}
|
||||
|
||||
@@ -225,6 +237,14 @@ export function useDropListeners({
|
||||
const sink = getSink();
|
||||
if (!sink) return;
|
||||
|
||||
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
|
||||
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
|
||||
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
|
||||
if (payload) {
|
||||
sink.onWorkspaceFile(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||
kind: "web-file",
|
||||
|
||||
@@ -15,35 +15,26 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
@@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
}
|
||||
|
||||
interface TreeRowItemProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
@@ -70,21 +63,8 @@ interface TreeRowItemProps {
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
}
|
||||
|
||||
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
|
||||
function menuButtonStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||
return [
|
||||
styles.menuButton,
|
||||
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
|
||||
];
|
||||
onAddToChat?: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function sortTriggerStyle({
|
||||
@@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
|
||||
}
|
||||
|
||||
function TreeRowItem({
|
||||
serverId,
|
||||
workspaceId,
|
||||
entry,
|
||||
depth,
|
||||
isExpanded,
|
||||
@@ -111,10 +93,17 @@ function TreeRowItem({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
testID,
|
||||
}: TreeRowItemProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isDirectory = entry.kind === "directory";
|
||||
const dragSourceRef = useWorkspaceFileDragSource({
|
||||
enabled: !isDirectory,
|
||||
serverId,
|
||||
workspaceId,
|
||||
path: entry.path,
|
||||
});
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onEntryPress(entry);
|
||||
@@ -123,10 +112,10 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.entryRow,
|
||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||
],
|
||||
[depth, isSelected, theme.spacing],
|
||||
[depth, isSelected],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -137,19 +126,38 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
const copyLeading = useMemo(
|
||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const downloadLeading = useMemo(
|
||||
() => <Download size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
const handleAddToChat = useCallback(() => {
|
||||
onAddToChat?.(entry.path);
|
||||
}, [onAddToChat, entry.path]);
|
||||
|
||||
const metaHeader = useMemo(
|
||||
() => (
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[entry.modifiedAt, entry.size, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View style={styles.entryInfo}>
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
@@ -163,40 +171,15 @@ function TreeRowItem({
|
||||
{entry.name}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
||||
{t("workspace.fileExplorer.context.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
{entry.kind === "file" ? (
|
||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
||||
{t("workspace.fileExplorer.context.download")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FileActionsMenu
|
||||
fileKind={entry.kind}
|
||||
onCopyPath={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
header={metaHeader}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -206,6 +189,7 @@ interface FileExplorerPaneProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -218,14 +202,10 @@ export function FileExplorerPane({
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -235,10 +215,6 @@ export function FileExplorerPane({
|
||||
}),
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const workspaceScopeId = useMemo(
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||
const explorerState = useSessionStore((state) =>
|
||||
workspaceStateKey && state.sessions[serverId]
|
||||
@@ -246,12 +222,16 @@ export function FileExplorerPane({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const downloadFile = useFileDownload({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
@@ -346,18 +326,14 @@ export function FileExplorerPane({
|
||||
[normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) =>
|
||||
downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}),
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
(entry: ExplorerEntry) => {
|
||||
if (entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
downloadFile({ fileName: entry.name, path: entry.path });
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -419,6 +395,8 @@ export function FileExplorerPane({
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
info={info}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedEntryPath={selectedEntryPath}
|
||||
@@ -426,6 +404,7 @@ export function FileExplorerPane({
|
||||
onEntryPress={handleEntryPress}
|
||||
onCopyPath={handleCopyPath}
|
||||
onDownloadEntry={handleDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -435,6 +414,9 @@ export function FileExplorerPane({
|
||||
handleDownloadEntry,
|
||||
isDirectoryLoading,
|
||||
selectedEntryPath,
|
||||
onAddToChat,
|
||||
serverId,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={sortTriggerStyleProp}
|
||||
testID="files-sort-trigger"
|
||||
>
|
||||
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||
{currentSortLabel}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||
testID="files-hidden-toggle"
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
testID="files-refresh"
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -778,39 +768,6 @@ function resolveTreeRows({
|
||||
});
|
||||
}
|
||||
|
||||
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
|
||||
type StartDownloadParams = Parameters<StartDownloadFn>[0];
|
||||
|
||||
function downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}: {
|
||||
entry: ExplorerEntry;
|
||||
workspaceScopeId: string | undefined;
|
||||
serverId: string;
|
||||
daemonProfile: StartDownloadParams["daemonProfile"];
|
||||
startDownload: StartDownloadFn;
|
||||
requestFileDownloadToken: (
|
||||
targetPath: string,
|
||||
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
|
||||
}): void {
|
||||
if (!workspaceScopeId || entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
startDownload({
|
||||
serverId,
|
||||
scopeId: workspaceScopeId,
|
||||
fileName: entry.name,
|
||||
path: entry.path,
|
||||
daemonProfile,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDirectory({
|
||||
entry,
|
||||
workspaceStateKey,
|
||||
@@ -850,6 +807,8 @@ function toggleDirectory({
|
||||
}
|
||||
|
||||
function TreeRowDispatcher({
|
||||
serverId,
|
||||
workspaceId,
|
||||
info,
|
||||
expandedPaths,
|
||||
selectedEntryPath,
|
||||
@@ -857,7 +816,10 @@ function TreeRowDispatcher({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
@@ -865,6 +827,7 @@ function TreeRowDispatcher({
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void | Promise<void>;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}) {
|
||||
const entry = info.item.entry;
|
||||
const depth = info.item.depth;
|
||||
@@ -875,6 +838,8 @@ function TreeRowDispatcher({
|
||||
|
||||
return (
|
||||
<TreeRowItem
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
entry={entry}
|
||||
depth={depth}
|
||||
isExpanded={isExpanded}
|
||||
@@ -883,6 +848,8 @@ function TreeRowDispatcher({
|
||||
onEntryPress={onEntryPress}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownloadEntry={onDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
testID={`file-explorer-row-${info.index}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
entriesContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
@@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
paddingRight: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -1133,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
menuButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
contextMetaBlock: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
|
||||
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (input.attachment.kind === "file") {
|
||||
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildForgeAttachmentFromSearchItem,
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -49,6 +50,11 @@ export function splitComposerAttachmentsForSubmit(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.kind === "workspace_file") {
|
||||
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||
images.push(attachment.attachment.screenshot);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
|
||||
const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
@@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
useDraftStore.setState({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
@@ -424,6 +429,39 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
||||
const image: AttachmentMetadata = {
|
||||
id: "legacy-image",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/legacy-image",
|
||||
createdAt: 10,
|
||||
};
|
||||
useDraftStore.setState({
|
||||
drafts: {
|
||||
"draft:legacy-workspace-file": {
|
||||
input: { text: "legacy text", images: [image] },
|
||||
lifecycle: "active",
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
} as unknown as DraftRecordForTest,
|
||||
},
|
||||
});
|
||||
|
||||
await useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: "draft:legacy-workspace-file",
|
||||
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||
});
|
||||
|
||||
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
||||
text: "legacy text",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("clear resets text and attachments", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftAgentControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { toDraftInputIfReady } from "@/stores/draft-store/state";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -57,6 +57,7 @@ export interface AgentInputDraft {
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
attachmentFocusRequestId: number;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
@@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const draftGenerationRef = useRef(0);
|
||||
const hydratedGenerationRef = useRef(0);
|
||||
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
|
||||
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
|
||||
const attachmentFocusRequestId = useDraftStore(
|
||||
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
||||
);
|
||||
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||
const text = draft?.text ?? "";
|
||||
const attachments = draft?.attachments ?? [];
|
||||
const isHydrated = hydratedDraftKey === draftKey;
|
||||
|
||||
const setAttachments = useCallback((updater: AttachmentUpdater) => {
|
||||
setAttachmentsState((previousAttachments) => {
|
||||
if (typeof updater === "function") {
|
||||
return updater(previousAttachments);
|
||||
const saveDraft = useCallback(
|
||||
(
|
||||
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
},
|
||||
) => {
|
||||
const store = useDraftStore.getState();
|
||||
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
|
||||
const next = update(current);
|
||||
if (!hasDraftContent(next)) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
return;
|
||||
}
|
||||
return updater;
|
||||
});
|
||||
}, []);
|
||||
store.saveDraftInput({ draftKey, draft: next });
|
||||
},
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
(nextText: string) => {
|
||||
saveDraft((current) => ({ ...current, text: nextText }));
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const setAttachments = useCallback(
|
||||
(updater: AttachmentUpdater) => {
|
||||
saveDraft((current) => ({
|
||||
...current,
|
||||
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
|
||||
}));
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
const store = useDraftStore.getState();
|
||||
store.clearDraftInput({ draftKey, lifecycle });
|
||||
|
||||
const generation = store.beginDraftGeneration(draftKey);
|
||||
draftGenerationRef.current = generation;
|
||||
hydratedGenerationRef.current = generation;
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setIsHydrated(true);
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
},
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const store = useDraftStore.getState();
|
||||
const generation = store.beginDraftGeneration(draftKey);
|
||||
draftGenerationRef.current = generation;
|
||||
hydratedGenerationRef.current = 0;
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setIsHydrated(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const draft = await store.hydrateDraftInput({
|
||||
draftKey,
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
setText(draft.text);
|
||||
setAttachmentsState(draft.attachments);
|
||||
}
|
||||
|
||||
hydratedGenerationRef.current = generation;
|
||||
setIsHydrated(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -145,52 +145,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
};
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentGeneration = draftGenerationRef.current;
|
||||
if (currentGeneration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useDraftStore.getState();
|
||||
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
||||
draftKey,
|
||||
generation: currentGeneration,
|
||||
});
|
||||
if (!isCurrentGeneration) {
|
||||
return;
|
||||
}
|
||||
if (hydratedGenerationRef.current !== currentGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.getDraftInput(draftKey);
|
||||
const isSameDraft =
|
||||
existing !== undefined &&
|
||||
existing.text === text &&
|
||||
areAttachmentsEqual({
|
||||
left: existing.attachments,
|
||||
right: attachments,
|
||||
});
|
||||
if (isSameDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasDraftContent({ text, attachments })) {
|
||||
if (existing) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
store.saveDraftInput({
|
||||
draftKey,
|
||||
draft: {
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
}, [attachments, draftKey, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
@@ -304,6 +258,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
@@ -765,6 +766,7 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
|
||||
49
packages/app/src/composer/focused-chat-target.test.ts
Normal file
49
packages/app/src/composer/focused-chat-target.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { resolveFocusedChatTarget } from "./focused-chat-target";
|
||||
|
||||
function layoutWithTarget(
|
||||
target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"],
|
||||
): WorkspaceLayout {
|
||||
return {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
tabIds: ["focused-tab"],
|
||||
focusedTabId: "focused-tab",
|
||||
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "pane-1",
|
||||
} as WorkspaceLayout;
|
||||
}
|
||||
|
||||
describe("focused chat target", () => {
|
||||
it("targets the focused agent draft", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
|
||||
}),
|
||||
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
|
||||
});
|
||||
|
||||
it("targets the focused unsent draft", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
|
||||
}),
|
||||
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
|
||||
});
|
||||
|
||||
it("does not guess when the focused tab is not a chat", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
48
packages/app/src/composer/focused-chat-target.ts
Normal file
48
packages/app/src/composer/focused-chat-target.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
export interface FocusedChatTarget {
|
||||
tabId: string;
|
||||
draftKey: string;
|
||||
}
|
||||
|
||||
export function resolveFocusedChatTarget(input: {
|
||||
serverId: string;
|
||||
layout: WorkspaceLayout | undefined;
|
||||
}): FocusedChatTarget | null {
|
||||
if (!input.layout) {
|
||||
return null;
|
||||
}
|
||||
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||
const focusedTabId = pane?.focusedTabId;
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tab = collectAllTabs(input.layout.root).find(
|
||||
(candidate) => candidate.tabId === focusedTabId,
|
||||
);
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId: input.serverId,
|
||||
agentId: tab.tabId,
|
||||
draftId: tab.target.draftId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
@@ -119,6 +120,15 @@ import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
getWorkspaceFileAttachmentKey,
|
||||
getWorkspaceFileAttachmentSubtitle,
|
||||
} from "@/attachments/workspace-file";
|
||||
import {
|
||||
resolveWorkspaceFileDrop,
|
||||
type WorkspaceFileDragPayload,
|
||||
} from "@/attachments/workspace-file-drag";
|
||||
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
@@ -399,6 +409,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (attachment.kind === "workspace_file") {
|
||||
return (
|
||||
<WorkspaceFileAttachmentPill
|
||||
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
removeLabel={labels.removeFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
return composerWorkspaceAttachment.renderPill({
|
||||
attachment,
|
||||
@@ -711,6 +733,43 @@ function FileAttachmentPill({
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceFileAttachmentPillProps {
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onRemove: (index: number) => void;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
function WorkspaceFileAttachmentPill({
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: WorkspaceFileAttachmentPillProps) {
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [index, onRemove]);
|
||||
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-workspace-file-attachment-pill"
|
||||
onOpen={noopCallback}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={fileName}
|
||||
removeAccessibilityLabel={removeLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={filePillIcon}
|
||||
title={fileName}
|
||||
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
|
||||
interface GithubPickerOptionProps {
|
||||
label: string;
|
||||
testID: string;
|
||||
@@ -755,6 +814,7 @@ function GithubPickerOption({
|
||||
interface ComposerProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||
@@ -786,6 +846,8 @@ interface ComposerProps {
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
/** When true, auto-focuses the text input on web. */
|
||||
autoFocus?: boolean;
|
||||
/** Changing this value requests focus again while autoFocus remains true. */
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
@@ -977,6 +1039,7 @@ function ComposerVoiceModeButton({
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
onClientSlashCommand,
|
||||
@@ -1000,6 +1063,7 @@ export function Composer({
|
||||
cwd,
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
@@ -1195,6 +1259,21 @@ export function Composer({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceFileDropped = useCallback(
|
||||
(payload: WorkspaceFileDragPayload) => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
|
||||
focusInput();
|
||||
},
|
||||
[focusInput, serverId, setSelectedAttachments, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onFocusInput?.(focusInput);
|
||||
}, [focusInput, onFocusInput]);
|
||||
@@ -1969,7 +2048,11 @@ export function Composer({
|
||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
||||
useFileDrop(
|
||||
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
|
||||
{
|
||||
onFiles: addImages,
|
||||
onGenericFiles: handleGenericFilesDropped,
|
||||
onWorkspaceFile: handleWorkspaceFileDropped,
|
||||
},
|
||||
{ disabled: isSubmitBusy },
|
||||
);
|
||||
|
||||
@@ -2030,7 +2113,7 @@ export function Composer({
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
|
||||
import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
@@ -80,7 +86,12 @@ export function DiffFolderRow({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<DiffStat additions={additions} deletions={deletions} />
|
||||
<DiffStat
|
||||
additions={additions}
|
||||
deletions={deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
<View style={styles.actionSlot} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
folderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
@@ -113,6 +124,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
actionSlot: {
|
||||
width: FILE_ACTIONS_MENU_WIDTH,
|
||||
},
|
||||
folderName: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
|
||||
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
|
||||
import { DiffFolderRow } from "@/git/diff-folder-row";
|
||||
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
import {
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
@@ -83,6 +87,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { FILE_ACTIONS_MENU_WIDTH, FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
@@ -106,6 +114,7 @@ import {
|
||||
hasVisibleDiffTokens,
|
||||
} from "@/utils/diff-rendering";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
import {
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
@@ -202,6 +211,7 @@ function HighlightedText({
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
isExpanded: boolean;
|
||||
/** Tree indentation level (0 on the flat/mobile path). */
|
||||
depth?: number;
|
||||
@@ -209,6 +219,10 @@ interface DiffFileSectionProps {
|
||||
showDir?: boolean;
|
||||
interactive?: boolean;
|
||||
onToggle?: (path: string) => void;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onDownload?: (path: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -905,18 +919,31 @@ function SplitDiffColumn({
|
||||
|
||||
const DiffFileHeader = memo(function DiffFileHeader({
|
||||
file,
|
||||
workspaceFileDragScope,
|
||||
isExpanded,
|
||||
depth = 0,
|
||||
showDir = true,
|
||||
interactive = true,
|
||||
onToggle,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
}: DiffFileSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const dragSourceRef = useWorkspaceFileDragSource({
|
||||
enabled: interactive,
|
||||
disabled: file.isDeleted,
|
||||
workspaceId: null,
|
||||
path: file.path,
|
||||
...workspaceFileDragScope,
|
||||
});
|
||||
const layoutYRef = useRef<number | null>(null);
|
||||
const pressHandledRef = useRef(false);
|
||||
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
|
||||
const [isActionsOpen, setIsActionsOpen] = useState(false);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
if (!interactive) {
|
||||
@@ -926,6 +953,31 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
onToggle?.(file.path);
|
||||
}, [file.path, interactive, onToggle]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
onOpenFile?.(file.path);
|
||||
}, [file.path, onOpenFile]);
|
||||
|
||||
const handleAddToChat = useCallback(() => {
|
||||
onAddToChat?.(file.path);
|
||||
}, [file.path, onAddToChat]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
onCopyPath?.(file.path);
|
||||
}, [file.path, onCopyPath]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
onDownload?.(file.path);
|
||||
}, [file.path, onDownload]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsActionsOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
@@ -983,7 +1035,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
const fileName = file.path.split("/").pop() ?? file.path;
|
||||
const headerContent = (
|
||||
<>
|
||||
<View style={styles.fileHeaderLeft}>
|
||||
<View ref={dragSourceRef} style={styles.fileHeaderLeft}>
|
||||
{showDir ? null : (
|
||||
<View style={styles.fileIcon}>
|
||||
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
|
||||
@@ -1013,38 +1065,55 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.fileHeaderRight}>
|
||||
<DiffStat additions={file.additions} deletions={file.deletions} />
|
||||
<DiffStat
|
||||
additions={file.additions}
|
||||
deletions={file.deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
{interactive ? (
|
||||
<FileActionsMenu
|
||||
fileKind="file"
|
||||
fileExists={!file.isDeleted}
|
||||
onOpenFile={onOpenFile ? handleOpenFile : undefined}
|
||||
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||
onDownload={onDownload ? handleDownload : undefined}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
open={isActionsOpen}
|
||||
onOpenChange={setIsActionsOpen}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
let trigger: ReactElement;
|
||||
if (!interactive) {
|
||||
trigger = (
|
||||
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
|
||||
);
|
||||
} else {
|
||||
trigger = (
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={headerPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={toggleExpanded}
|
||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{headerContent}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
{interactive ? (
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={headerPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={toggleExpanded}
|
||||
>
|
||||
{headerContent}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={headerPressableStyle({ hovered: false, pressed: false })}>
|
||||
{headerContent}
|
||||
</View>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
|
||||
<Text style={styles.tooltipText}>{file.path}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{trigger}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -1242,6 +1311,8 @@ interface GitDiffPaneProps {
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
type PressableStyleFn = (
|
||||
@@ -1639,6 +1710,11 @@ interface SharedDiffViewProps {
|
||||
expandedPaths: string[];
|
||||
collapsedFolders: string[];
|
||||
reviewActions?: InlineReviewActions;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onDownload?: (path: string) => void;
|
||||
onExpandedPathsChange: (paths: string[]) => void;
|
||||
onCollapsedFoldersChange: (paths: string[]) => void;
|
||||
}
|
||||
@@ -1671,6 +1747,12 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
const stickyHeaders = mode.kind === "working_tree";
|
||||
const interactive = mode.kind === "working_tree";
|
||||
const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined;
|
||||
const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined;
|
||||
const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined;
|
||||
const workspaceFileDragScope =
|
||||
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
|
||||
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
|
||||
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
|
||||
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
||||
@@ -1917,11 +1999,16 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
return (
|
||||
<DiffFileHeader
|
||||
file={item.file}
|
||||
workspaceFileDragScope={workspaceFileDragScope}
|
||||
isExpanded={item.isExpanded}
|
||||
depth={item.depth}
|
||||
showDir={viewMode === "flat"}
|
||||
interactive={interactive}
|
||||
onToggle={interactive ? handleToggleExpanded : undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={onAddToChat}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onHeaderHeightChange={handleHeaderHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}`}
|
||||
/>
|
||||
@@ -1949,10 +2036,15 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
handleToggleFolder,
|
||||
layout,
|
||||
reviewActions,
|
||||
workspaceFileDragScope,
|
||||
textMetricsStyle,
|
||||
viewMode,
|
||||
wrapLines,
|
||||
interactive,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1982,6 +2074,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
viewMode,
|
||||
wrapLines,
|
||||
reviewActions,
|
||||
workspaceFileDragScope,
|
||||
}),
|
||||
[
|
||||
expandedPathsArray,
|
||||
@@ -1991,6 +2084,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
reviewActions,
|
||||
typographyKey,
|
||||
viewMode,
|
||||
workspaceFileDragScope,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
@@ -2160,6 +2254,13 @@ function buildExpandAllButtonStyle(): PressableStyleFn {
|
||||
];
|
||||
}
|
||||
|
||||
function buildOverflowButtonStyle(): PressableStyleFn {
|
||||
return ({ hovered, pressed }) => [
|
||||
styles.overflowButton,
|
||||
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
|
||||
];
|
||||
}
|
||||
|
||||
function buildToggleButtonStyle(
|
||||
selected: boolean,
|
||||
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
||||
@@ -2174,7 +2275,14 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean
|
||||
return input.paneEnabled && input.isGit;
|
||||
}
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
|
||||
export function GitDiffPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
enabled,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
}: GitDiffPaneProps) {
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -2214,7 +2322,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
|
||||
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||
|
||||
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
|
||||
|
||||
const toast = useToast();
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
@@ -2461,6 +2569,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
},
|
||||
[setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
|
||||
);
|
||||
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd });
|
||||
const handleCopyPath = useCallback(
|
||||
(path: string) => {
|
||||
void Clipboard.setStringAsync(
|
||||
buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
|
||||
);
|
||||
},
|
||||
[cwd],
|
||||
);
|
||||
const handleDownloadPath = useCallback(
|
||||
(path: string) => {
|
||||
downloadFile({ fileName: path.split("/").pop() ?? path, path });
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
const workingTreeMode = useMemo(
|
||||
() => ({
|
||||
kind: "working_tree" as const,
|
||||
@@ -2468,6 +2591,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
expandedPaths: stableExpandedPathsArray,
|
||||
collapsedFolders: stableCollapsedFoldersArray,
|
||||
reviewActions,
|
||||
workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath: handleCopyPath,
|
||||
onDownload: handleDownloadPath,
|
||||
onExpandedPathsChange: handleExpandedPathsChange,
|
||||
onCollapsedFoldersChange: handleCollapsedFoldersChange,
|
||||
}),
|
||||
@@ -2476,6 +2604,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
stableExpandedPathsArray,
|
||||
stableCollapsedFoldersArray,
|
||||
reviewActions,
|
||||
serverId,
|
||||
workspaceId,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
handleCopyPath,
|
||||
handleDownloadPath,
|
||||
handleExpandedPathsChange,
|
||||
handleCollapsedFoldersChange,
|
||||
],
|
||||
@@ -2742,6 +2876,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
overflowButton: {
|
||||
width: FILE_ACTIONS_MENU_WIDTH,
|
||||
height: {
|
||||
xs: 32,
|
||||
sm: 32,
|
||||
md: 24,
|
||||
},
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
actionErrorText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
@@ -2830,8 +2976,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
zIndex: 2,
|
||||
|
||||
56
packages/app/src/hooks/use-file-download.ts
Normal file
56
packages/app/src/hooks/use-file-download.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
interface UseFileDownloadParams {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable callback that downloads a single workspace file by its
|
||||
* workspace-relative path. Shared by the file explorer tree and the git diff
|
||||
* pane so both surfaces download through the same host token + download-store
|
||||
* pipeline instead of duplicating the plumbing.
|
||||
*/
|
||||
export function useFileDownload({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
}: UseFileDownloadParams): (input: { fileName: string; path: string }) => void {
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceScopeId = useMemo(
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
|
||||
return useCallback(
|
||||
({ fileName, path }) => {
|
||||
if (!workspaceScopeId) {
|
||||
return;
|
||||
}
|
||||
void startDownload({
|
||||
serverId,
|
||||
scopeId: workspaceScopeId,
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
});
|
||||
},
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
);
|
||||
}
|
||||
@@ -366,6 +366,13 @@ export const ar: TranslationResources = {
|
||||
copyBranchName: "نسخ اسم الفرع",
|
||||
copied: "تم النسخ",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "افتح الملف",
|
||||
copyPath: "نسخ المسار",
|
||||
download: "تحميل",
|
||||
addToChat: "إضافة إلى الدردشة…",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "اسم",
|
||||
@@ -375,8 +382,6 @@ export const ar: TranslationResources = {
|
||||
context: {
|
||||
size: "مقاس",
|
||||
modified: "معدل",
|
||||
copyPath: "نسخ المسار",
|
||||
download: "تحميل",
|
||||
},
|
||||
actions: {
|
||||
back: "خلف",
|
||||
|
||||
@@ -365,6 +365,13 @@ export const en = {
|
||||
copyBranchName: "Copy branch name",
|
||||
copied: "Copied",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Open file",
|
||||
copyPath: "Copy path",
|
||||
download: "Download",
|
||||
addToChat: "Add to chat…",
|
||||
moreActions: "More actions",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Name",
|
||||
@@ -374,8 +381,6 @@ export const en = {
|
||||
context: {
|
||||
size: "Size",
|
||||
modified: "Modified",
|
||||
copyPath: "Copy path",
|
||||
download: "Download",
|
||||
},
|
||||
actions: {
|
||||
back: "Back",
|
||||
|
||||
@@ -370,6 +370,13 @@ export const es: TranslationResources = {
|
||||
copyBranchName: "Copiar nombre de rama",
|
||||
copied: "Copiado",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Abrir archivo",
|
||||
copyPath: "Copiar ruta",
|
||||
download: "Descargar",
|
||||
addToChat: "Añadir al chat…",
|
||||
moreActions: "Más acciones",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Nombre",
|
||||
@@ -379,8 +386,6 @@ export const es: TranslationResources = {
|
||||
context: {
|
||||
size: "Tamaño",
|
||||
modified: "Modificado",
|
||||
copyPath: "Copiar ruta",
|
||||
download: "Descargar",
|
||||
},
|
||||
actions: {
|
||||
back: "Atrás",
|
||||
|
||||
@@ -370,6 +370,13 @@ export const fr: TranslationResources = {
|
||||
copyBranchName: "Copier le nom de la branche",
|
||||
copied: "Copié",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Ouvrir le fichier",
|
||||
copyPath: "Copier le chemin",
|
||||
download: "Télécharger",
|
||||
addToChat: "Ajouter au chat…",
|
||||
moreActions: "Plus de propositions",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Nom",
|
||||
@@ -379,8 +386,6 @@ export const fr: TranslationResources = {
|
||||
context: {
|
||||
size: "Taille",
|
||||
modified: "Modifié",
|
||||
copyPath: "Copier le chemin",
|
||||
download: "Télécharger",
|
||||
},
|
||||
actions: {
|
||||
back: "Dos",
|
||||
|
||||
@@ -370,6 +370,13 @@ export const ja: TranslationResources = {
|
||||
copyBranchName: "ブランチ名をコピー",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "ファイルを開く",
|
||||
copyPath: "パスをコピー",
|
||||
download: "ダウンロード",
|
||||
addToChat: "チャットに追加…",
|
||||
moreActions: "その他のアクション",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "名前",
|
||||
@@ -379,8 +386,6 @@ export const ja: TranslationResources = {
|
||||
context: {
|
||||
size: "サイズ",
|
||||
modified: "更新日時",
|
||||
copyPath: "パスをコピー",
|
||||
download: "ダウンロード",
|
||||
},
|
||||
actions: {
|
||||
back: "戻る",
|
||||
|
||||
@@ -370,6 +370,13 @@ export const ptBR: TranslationResources = {
|
||||
copyBranchName: "Copiar nome da branch",
|
||||
copied: "Copiado",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Abrir arquivo",
|
||||
copyPath: "Copiar caminho",
|
||||
download: "Baixar",
|
||||
addToChat: "Adicionar ao chat…",
|
||||
moreActions: "Mais ações",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Nome",
|
||||
@@ -379,8 +386,6 @@ export const ptBR: TranslationResources = {
|
||||
context: {
|
||||
size: "Tamanho",
|
||||
modified: "Modificado",
|
||||
copyPath: "Copiar caminho",
|
||||
download: "Baixar",
|
||||
},
|
||||
actions: {
|
||||
back: "Voltar",
|
||||
|
||||
@@ -369,6 +369,13 @@ export const ru: TranslationResources = {
|
||||
copyBranchName: "Копировать имя ветки",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Открыть файл",
|
||||
copyPath: "Копировать путь",
|
||||
download: "Скачать",
|
||||
addToChat: "Добавить в чат…",
|
||||
moreActions: "Дополнительные действия",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Имя",
|
||||
@@ -378,8 +385,6 @@ export const ru: TranslationResources = {
|
||||
context: {
|
||||
size: "Размер",
|
||||
modified: "Модифицированный",
|
||||
copyPath: "Копировать путь",
|
||||
download: "Скачать",
|
||||
},
|
||||
actions: {
|
||||
back: "Назад",
|
||||
|
||||
@@ -366,6 +366,13 @@ export const zhCN: TranslationResources = {
|
||||
copyBranchName: "复制分支名称",
|
||||
copied: "已复制",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "打开文件",
|
||||
copyPath: "复制路径",
|
||||
download: "下载",
|
||||
addToChat: "添加到聊天…",
|
||||
moreActions: "更多操作",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "名称",
|
||||
@@ -375,8 +382,6 @@ export const zhCN: TranslationResources = {
|
||||
context: {
|
||||
size: "大小",
|
||||
modified: "修改时间",
|
||||
copyPath: "复制路径",
|
||||
download: "下载",
|
||||
},
|
||||
actions: {
|
||||
back: "返回",
|
||||
|
||||
@@ -1160,8 +1160,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
|
||||
// when only toast state changes (which does not affect any draft field).
|
||||
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
|
||||
rawAgentInputDraft;
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
} = rawAgentInputDraft;
|
||||
const agentInputDraft = useMemo(
|
||||
(): AgentInputDraft => ({
|
||||
text,
|
||||
@@ -1170,9 +1178,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
}),
|
||||
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
|
||||
[
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const streamSection = (
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
@@ -1537,6 +1555,7 @@ function ActiveAgentComposer({
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
@@ -1548,6 +1567,7 @@ function ActiveAgentComposer({
|
||||
cwd={cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
autoFocus={isPaneFocused}
|
||||
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
|
||||
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
garbageCollectAttachments,
|
||||
persistAttachmentFromDataUrl,
|
||||
@@ -39,16 +40,21 @@ interface DraftStoreActions {
|
||||
draftKey: string;
|
||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||
}) => void;
|
||||
attachWorkspaceFile: (input: {
|
||||
draftKey: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
}) => Promise<void>;
|
||||
getCreateModalDraft: () => DraftInput | null;
|
||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||
beginDraftGeneration: (draftKey: string) => number;
|
||||
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
|
||||
collectActiveAttachmentIds: () => string[];
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
|
||||
const draftGenerations = new Map<string, number>();
|
||||
let gcScheduled = false;
|
||||
const draftPersistStorage = createDraftPersistStorage(
|
||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
|
||||
return { drafts: nextDrafts };
|
||||
});
|
||||
|
||||
draftGenerations.delete(draftKey);
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||
await get().hydrateDraftInput({ draftKey });
|
||||
set((state) => {
|
||||
const existing = state.drafts[draftKey];
|
||||
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[draftKey]: createDraftRecord({
|
||||
draft: {
|
||||
...draft,
|
||||
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
|
||||
},
|
||||
lifecycle: "active",
|
||||
previousVersion: existing?.version,
|
||||
}),
|
||||
},
|
||||
attachmentFocusRequestByDraftKey: {
|
||||
...state.attachmentFocusRequestByDraftKey,
|
||||
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
@@ -369,16 +401,6 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
beginDraftGeneration: (draftKey) => {
|
||||
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
|
||||
draftGenerations.set(draftKey, next);
|
||||
return next;
|
||||
},
|
||||
|
||||
isDraftGenerationCurrent: ({ draftKey, generation }) => {
|
||||
return (draftGenerations.get(draftKey) ?? 0) === generation;
|
||||
},
|
||||
|
||||
collectActiveAttachmentIds: () => {
|
||||
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
|
||||
},
|
||||
@@ -387,6 +409,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
name: "paseo-drafts",
|
||||
version: DRAFT_STORE_VERSION,
|
||||
storage: draftPersistStorage,
|
||||
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
|
||||
migrate: (persistedState) => {
|
||||
return migratePersistedState(persistedState, {
|
||||
migrateLegacyImages,
|
||||
|
||||
@@ -71,6 +71,46 @@ describe("draft-store lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("draft-store normalization", () => {
|
||||
it("preserves uploaded file attachments when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "file" as const,
|
||||
attachment: {
|
||||
type: "uploaded_file" as const,
|
||||
id: "file-1",
|
||||
fileName: "context.json",
|
||||
mimeType: "application/json",
|
||||
size: 42,
|
||||
path: "/tmp/context.json",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toDraftInputIfReady({
|
||||
input: { text: "Review this", attachments: [attachment] },
|
||||
lifecycle: "active",
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
}),
|
||||
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||
});
|
||||
|
||||
it("preserves workspace file selections when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "workspace_file" as const,
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range" as const, startLine: 12, endLine: 24 },
|
||||
};
|
||||
|
||||
expect(
|
||||
toDraftInputIfReady({
|
||||
input: { text: "Review this", attachments: [attachment] },
|
||||
lifecycle: "active",
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
}),
|
||||
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||
});
|
||||
|
||||
it("preserves New Workspace picker ownership when hydrating a draft", () => {
|
||||
const pickerAttachment = {
|
||||
kind: "github_pr" as const,
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
type AttachmentMetadata,
|
||||
type UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
|
||||
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
ForgeSearchItemSchema,
|
||||
GitHubSearchItemSchema,
|
||||
UploadedFileAttachmentSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export const DRAFT_STORE_VERSION = 5;
|
||||
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
|
||||
const metadata = record.metadata;
|
||||
return isAttachmentMetadata(metadata);
|
||||
}
|
||||
if (record.kind === "workspace_file") {
|
||||
return isWorkspaceFileComposerAttachment(value);
|
||||
}
|
||||
if (record.kind === "file") {
|
||||
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
|
||||
}
|
||||
if (
|
||||
record.kind !== "forge_issue" &&
|
||||
record.kind !== "forge_change_request" &&
|
||||
@@ -113,6 +124,13 @@ export function normalizeComposerAttachment(
|
||||
metadata: normalizeAttachmentMetadata(attachment.metadata),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "workspace_file") {
|
||||
return {
|
||||
kind: "workspace_file",
|
||||
path: attachment.path.trim().replace(/^\.\//, ""),
|
||||
selection: attachment.selection,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "github_pr") {
|
||||
const item =
|
||||
(attachment.item as { kind: string }).kind === "pr"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyFileMentionReplacement, findActiveFileMention } from "./file-mention-autocomplete";
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
formatQuotedFileMentionPath,
|
||||
} from "./file-mention-autocomplete";
|
||||
|
||||
describe("findActiveFileMention", () => {
|
||||
it("detects mentions at the start of input", () => {
|
||||
@@ -46,6 +50,14 @@ describe("findActiveFileMention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuotedFileMentionPath", () => {
|
||||
it("quotes workspace-relative paths using file mention escaping", () => {
|
||||
expect(formatQuotedFileMentionPath('src/changed "file".ts')).toBe(
|
||||
'"src/changed \\"file\\".ts"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFileMentionReplacement", () => {
|
||||
it("replaces only the active @query segment with a quoted relative path", () => {
|
||||
const text = "open @src/com next";
|
||||
|
||||
@@ -40,9 +40,13 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatQuotedFileMentionPath(relativePath: string): string {
|
||||
const safePath = relativePath.replace(/"/g, '\\"');
|
||||
return `"${safePath}"`;
|
||||
}
|
||||
|
||||
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
|
||||
const safePath = input.relativePath.replace(/"/g, '\\"');
|
||||
const before = input.text.slice(0, input.mention.start);
|
||||
const after = input.text.slice(input.mention.end);
|
||||
return `${before}"${safePath}"${after}`;
|
||||
return `${before}${formatQuotedFileMentionPath(input.relativePath)}${after}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user