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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 19 additions & 51 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,6 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions";
import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
import { PierreEntryIcon } from "./chat/PierreEntryIcon";
import {
revealInFileExplorerLabelForKind,
revealInFileExplorerLabelForOs,
} from "./preview/fileExplorerLabel";
import {
resolveExternalWebLinkHost,
showExternalLinkContextMenu,
Expand Down Expand Up @@ -139,6 +135,7 @@ import { HighlightedCodeLines } from "./chat/HighlightedCodeLines";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
import { runFileManagerPath, useFileManagerActionForEnvironment } from "../fileManagerReveal";
import {
chatMarkdownClipboardPayload,
serializeTableElementToCsv,
Expand All @@ -163,7 +160,6 @@ import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
import { useRightPanelStore } from "../rightPanelStore";
import { readThreadShell, useProjects } from "../state/entities";
import { serverEnvironment } from "../state/server";
import { shellEnvironment } from "../state/shell";
import { assetEnvironment } from "../state/assets";
import { usePreparedConnection } from "../state/session";
import { previewEnvironment } from "../state/preview";
Expand Down Expand Up @@ -1988,38 +1984,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
if (!onReveal) {
return;
}
void (async () => {
try {
const result = await onReveal();
if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
return;
}
reportMarkdownActionFailure(
{ operation: "reveal-file-in-file-manager", target: targetPath },
result.cause,
);
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to reveal file",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
} catch (cause) {
void runFileManagerPath(
() => onReveal(),
targetPath,
"Unable to reveal file",
(cause) =>
reportMarkdownActionFailure(
{ operation: "reveal-file-in-file-manager", target: targetPath },
cause,
);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to reveal file",
description: cause instanceof Error ? cause.message : "An error occurred.",
}),
);
}
})();
),
);
}, [onReveal, targetPath]);

const handleCopy = useCallback(
Expand Down Expand Up @@ -2343,17 +2317,10 @@ function useChatMarkdownState({
const [preferredEditor] = usePreferredEditor(availableEditors);
const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors);
const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
reportFailure: false,
});
const revealInFileManagerLabel =
environmentId !== null &&
serverConfig?.shellRevealInFileManager === true &&
serverConfig.availableEditors.includes("file-manager")
? serverConfig.shellRevealInFileManagerKind === undefined
? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os)
: revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind)
: undefined;
const environmentFileManagerAction = useFileManagerActionForEnvironment(environmentId);
const fileManagerAction = canUseShellActions ? environmentFileManagerAction : null;
const fileManagerReveal = fileManagerAction?.reveal ?? null;
const revealInFileManagerLabel = fileManagerReveal?.label;
const revealFileInFileManager = useCallback(
(filePath: string) => {
if (environmentId === null) {
Expand All @@ -2363,12 +2330,12 @@ function useChatMarkdownState({
),
);
}
return openInEditor({
environmentId,
input: { cwd: filePath, editor: "file-manager", reveal: true },
});
if (fileManagerReveal === null) {
return Promise.reject(new Error("File-manager reveal is unavailable."));
}
return fileManagerReveal.run(filePath);
},
[environmentId, openInEditor],
[environmentId, fileManagerReveal],
);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
Expand Down Expand Up @@ -2623,7 +2590,7 @@ function useChatMarkdownState({
}
openInEditorMenuLabel={preferredEditorMenuLabel}
onReveal={
canUseShellActions && revealInFileManagerLabel !== undefined
canUseShellActions && fileManagerReveal !== null
? () => revealMarkdownFileInFileManager(fileLinkMeta)
: undefined
}
Expand All @@ -2641,6 +2608,7 @@ function useChatMarkdownState({
},
[
canUseShellActions,
fileManagerReveal,
fileLinkParentSuffixByPath,
openFileInPanel,
openInPreferredEditor,
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ interface PersistentThreadTerminalDrawerProps {
closeShortcutLabel: string | undefined;
keybindings: ResolvedKeybindingsConfig;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
onAddTerminalLink?: (text: string) => void;
}

const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
Expand All @@ -911,6 +912,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
closeShortcutLabel,
keybindings,
onAddTerminalContext,
onAddTerminalLink,
}: PersistentThreadTerminalDrawerProps) {
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
Expand Down Expand Up @@ -1211,6 +1213,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
[onAddTerminalContext, visible],
);

const handleAddTerminalLink = useCallback(
(text: string) => {
if (!visible || !onAddTerminalLink) {
return;
}
onAddTerminalLink(text);
},
[onAddTerminalLink, visible],
);

if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) {
return null;
}
Expand Down Expand Up @@ -1252,6 +1264,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={handleAddTerminalContext}
onAddTerminalLink={onAddTerminalLink ? handleAddTerminalLink : undefined}
terminalLabelsById={terminalLabelsById}
terminalLaunchLocationsById={terminalLaunchLocationsById}
/>
Expand All @@ -1268,6 +1281,7 @@ interface PersistentThreadTerminalPanelProps {
focusRequestId: number;
keybindings: ResolvedKeybindingsConfig;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
onAddTerminalLink?: (text: string) => void;
onSplitTerminal: () => void;
onSplitTerminalVertical: () => void;
onNewTerminal: () => void;
Expand All @@ -1287,6 +1301,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
focusRequestId,
keybindings,
onAddTerminalContext,
onAddTerminalLink,
onSplitTerminal,
onSplitTerminalVertical,
onNewTerminal,
Expand Down Expand Up @@ -1337,6 +1352,15 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
: {},
[project, worktreePath],
);
const handleAddTerminalLink = useCallback(
(text: string) => {
if (!visible || !onAddTerminalLink) {
return;
}
onAddTerminalLink(text);
},
[onAddTerminalLink, visible],
);
const terminalLabelsById = useMemo(() => {
const labels = new Map<string, string>();
for (const terminalId of surface.terminalIds) {
Expand Down Expand Up @@ -1425,6 +1449,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
onCloseTerminal={onCloseTerminal}
onHeightChange={() => undefined}
onAddTerminalContext={onAddTerminalContext}
onAddTerminalLink={onAddTerminalLink ? handleAddTerminalLink : undefined}
terminalLabelsById={terminalLabelsById}
terminalLaunchLocationsById={terminalLaunchLocationsById}
keybindings={keybindings}
Expand Down Expand Up @@ -4028,6 +4053,18 @@ export default function ChatView(props: ChatViewProps) {
},
[composerRef],
);
const addTerminalLinkToDraft = useCallback(
(text: string) => {
if (!composerRef.current?.insertTextAtEnd(`${text} `, { ensureLeadingBoundary: true })) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "The composer is busy; try again once it is ready.",
});
}
},
[composerRef],
);
const setTerminalOpen = useCallback(
(open: boolean) => {
if (!activeThreadRef) return;
Expand Down Expand Up @@ -9621,6 +9658,7 @@ export default function ChatView(props: ChatViewProps) {
focusRequestId={terminalFocusRequestId}
keybindings={keybindings}
onAddTerminalContext={addTerminalContextToDraft}
onAddTerminalLink={addTerminalLinkToDraft}
onSplitTerminal={splitPanelTerminal}
onSplitTerminalVertical={splitPanelTerminalVertical}
onNewTerminal={addTerminalSurface}
Expand Down Expand Up @@ -10320,6 +10358,7 @@ export default function ChatView(props: ChatViewProps) {
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
keybindings={keybindings}
onAddTerminalContext={addTerminalContextToDraft}
onAddTerminalLink={addTerminalLinkToDraft}
/>
))}
</div>
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
terminalSelectionLineRange,
terminalSelectionMenuItems,
terminalThemeFromApp,
terminalLinkChatText,
terminalLinkCopyText,
terminalLinkTargetForEditor,
} from "./ThreadTerminalDrawer";

describe("terminal selection menus", () => {
Expand All @@ -27,6 +30,121 @@ describe("terminal selection menus", () => {
});
});

describe("terminal link menus", () => {
it("adds path actions before selection actions", () => {
const items = terminalContextMenuItems({
hasSelection: true,
link: { text: "src/main.ts:12", editorLabel: "Open in VS Code" },
});

expect(items.map(({ id }) => id)).toEqual([
"open-terminal-path",
"add-terminal-path-to-chat",
"copy-terminal-path",
"add-to-chat",
"copy",
"paste",
]);
expect(items[0]).toMatchObject({ id: "open-terminal-path", label: "Open in VS Code" });
expect(items.find(({ id }) => id === "add-to-chat")).toMatchObject({
separatorBefore: true,
});
expect(items.slice(0, 3).every((item) => !item.separatorBefore)).toBe(true);
});

it.each([
["with reveal", "Reveal in Finder", true],
["without reveal", undefined, false],
])("handles path reveal action %s", (_name, revealLabel, hasReveal) => {
const items = terminalContextMenuItems({
hasSelection: true,
link: { text: "src/main.ts:12", ...(revealLabel ? { revealLabel } : {}) },
});
const ids = items.map(({ id }) => id);
expect(ids.includes("reveal-terminal-path")).toBe(hasReveal);
if (hasReveal) {
expect(ids.indexOf("reveal-terminal-path")).toBe(ids.indexOf("open-terminal-path") + 1);
}
});

it("does not add reveal action to URL links", () => {
expect(
terminalContextMenuItems({
hasSelection: true,
link: { text: "https://example.com", revealLabel: "Reveal in Finder" },
}).map(({ id }) => id),
).not.toContain("reveal-terminal-path");
});

it("keeps the selection menu unchanged when there is no link", () => {
const items = terminalContextMenuItems({ hasSelection: true });

expect(items.map(({ id }) => id)).toEqual(["add-to-chat", "copy", "paste"]);
expect(items.every((item) => !item.separatorBefore)).toBe(true);
});

it("omits URL chat actions when adding to chat is unavailable", () => {
const items = terminalContextMenuItems({
hasSelection: true,
canAddToChat: false,
link: { text: "https://example.com", canOpenPreview: true },
});

expect(items.map(({ id }) => id)).not.toContain("add-terminal-link-to-chat");
expect(items.map(({ id }) => id)).not.toContain("add-to-chat");
});

it("gates the link add-to-chat item separately from the selection one", () => {
const ids = terminalContextMenuItems({
hasSelection: true,
link: { text: "src/main.ts", canAddToChat: false },
}).map(({ id }) => id);

expect(ids).not.toContain("add-terminal-path-to-chat");
expect(ids).toContain("add-to-chat");
});

it("places URL actions before selection actions", () => {
expect(
terminalContextMenuItems({
hasSelection: true,
link: { text: "https://example.com", canOpenPreview: true },
}).map(({ id }) => id),
).toEqual([
"open-terminal-link-preview",
"open-terminal-link-browser",
"add-terminal-link-to-chat",
"copy-terminal-link",
"add-to-chat",
"copy",
"paste",
]);
});

it("serializes paths for chat and strips positions for copying", () => {
expect(terminalLinkChatText("src/main.ts:12:3", "/repo")).toContain("src/main.ts");
expect(terminalLinkChatText("/", "/repo")).toBe("/");
expect(terminalLinkChatText("src\\", "/repo")).toBe("[src](/repo/src)");
expect(terminalLinkChatText("C:\\", "C:\\repo")).toBe("C:\\");
expect(terminalLinkChatText("https://example.com/a", "/repo")).toBe("https://example.com/a");
expect(terminalLinkCopyText("src/main.ts:12:3")).toBe("src/main.ts");
expect(terminalLinkCopyText("https://example.com:8080/a")).toBe("https://example.com:8080/a");
});

it("keeps Windows directory paths as directory links", () => {
expect(terminalLinkChatText("C:\\work\\", "C:\\repo")).toBe("C:\\work\\");
});

it("strips positions only for the file manager", () => {
expect(terminalLinkTargetForEditor("/repo/src/main.ts:12:3", "file-manager")).toBe(
"/repo/src/main.ts",
);
expect(terminalLinkTargetForEditor("/repo/src/main.ts:12:3", "vscode")).toBe(
"/repo/src/main.ts:12:3",
);
});
});

describe("terminalThemeFromApp", () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
Loading
Loading