Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
56c6aba
feat(web): reveal chat file chips in the system file manager
Aug 15, 2026
3f25c28
fix(web): scope file chip actions to the thread's environment
Aug 15, 2026
9760c9f
fix(server): quote only the path when revealing in File Explorer
Aug 15, 2026
9ed4c1a
test(server): make File Explorer reveal coverage portable
SunkenInTime Aug 16, 2026
103b0e2
Merge remote-tracking branch 'upstream/main' into t3code/test-windows…
SunkenInTime Aug 16, 2026
f8df785
fix(server): make file reveal capability host-aware
SunkenInTime Aug 16, 2026
bfcc4fe
fix(server): file reveal works with spaced paths and headless Linux h…
Aug 19, 2026
1f41727
docs(macroscope): teach the UI review the environment-routing rule
Aug 19, 2026
cd4870b
test(server): let the Windows reveal smoke test read the real host pl…
Aug 19, 2026
4da7c63
Merge remote-tracking branch 'upstream/main' into feat/reveal-file-ch…
Aug 19, 2026
ff1122a
fix(web): pass the environment to timeline event markdown bodies
Aug 19, 2026
7f9f72a
fix(server): keep the Linux file manager as a WSL fallback
Aug 19, 2026
fa74a79
fix(server): keep the reveal action on the file manager the label pro…
Aug 19, 2026
3e24ad7
feat(web): name editors in file menus
SunkenInTime Aug 22, 2026
201b21a
fix(web): scope file manager labels to environment
SunkenInTime Aug 22, 2026
965756c
Merge branch 'main' of https://github.com/pingdotgg/t3code into t3cod…
SunkenInTime Aug 22, 2026
f128de8
fix(web): tighten file chip actions
SunkenInTime Aug 22, 2026
f76185a
Merge branch 'main' of https://github.com/pingdotgg/t3code into t3cod…
SunkenInTime Aug 22, 2026
c46b39a
test(web): mock file chip editor preference
SunkenInTime Aug 22, 2026
74bcda7
fix(server): bound reveal capability discovery
SunkenInTime Aug 22, 2026
3fb8488
fix(web): hide remote file chip shell actions
SunkenInTime Aug 25, 2026
fc6fddb
fix(web): keep unavailable remote file chips static
SunkenInTime Aug 25, 2026
216c39b
fix(web): wait for remote state before shell actions
SunkenInTime Aug 25, 2026
177bd32
fix(web): keep remote root file chips static
SunkenInTime Aug 25, 2026
063b398
fix(web): make remote file options accessible
SunkenInTime Aug 25, 2026
8051b66
Merge remote-tracking branch 'upstream/main' into t3code/hide-editor-…
SunkenInTime Aug 25, 2026
ba1b443
fix(web): keep file chip fallbacks consistent
SunkenInTime Aug 25, 2026
47dd60f
fix(web): keep file chip contrast consistent
SunkenInTime Aug 25, 2026
1d6feec
Merge branch 'main' into feat/reveal-file-chip-in-file-manager
shivamhwp Aug 25, 2026
81058be
fix(web): keep chat file chip text selectable
shivamhwp Aug 25, 2026
d7ad5d1
Merge branch 'main' into feat/reveal-file-chip-in-file-manager
shivamhwp Aug 25, 2026
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
104 changes: 104 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,110 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reveals a file in Finder with open -R on macOS", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
const openPath = path.join(binDir, "open");
yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n");
yield* fileSystem.chmod(openPath, 0o755);

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "file-manager",
cwd: "/workspace/media/linux-mini-v2.mp4",
reveal: true,
});
}).pipe(
Effect.provide(
testLayer({
platform: "darwin",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.equal(spawned.command, "open");
assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reveals a file in File Explorer with /select on Windows", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "explorer.EXE"), "");

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "file-manager",
cwd: "C:\\workspace with spaces\\media\\clip.mp4",
reveal: true,
});
}).pipe(
Effect.provide(
testLayer({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.equal(spawned.command, "explorer");
// explorer.exe expects the switch and path as one comma-joined argument.
assert.deepEqual(spawned.args, ["/select,C:\\workspace with spaces\\media\\clip.mp4"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reveals by opening the containing directory on Linux", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
const xdgOpenPath = path.join(binDir, "xdg-open");
yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n");
yield* fileSystem.chmod(xdgOpenPath, 0o755);

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "file-manager",
cwd: "/workspace/media/linux-mini-v2.mp4",
reveal: true,
});
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.equal(spawned.command, "xdg-open");
assert.deepEqual(spawned.args, ["/workspace/media"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("discovers editors through the service API", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,16 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
}

if (input.reveal === true) {
const path = yield* Path.Path;
return {
editor: editorDef.id,
target: input.cwd,
command: fileManagerCommandForPlatform(platform),
args: fileManagerRevealArgs(input.cwd, platform, path),
};
}

return {
editor: editorDef.id,
target: input.cwd,
Expand All @@ -384,6 +394,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
};
});

function fileManagerRevealArgs(
target: string,
platform: NodeJS.Platform,
path: Path.Path,
): ReadonlyArray<string> {
switch (platform) {
case "darwin":
return ["-R", target];
case "win32":
// explorer.exe expects the switch and path as one comma-joined argument.
return [`/select,${target}`];
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
default:
// Linux file managers have no portable "select this file" flag, so open
// the containing directory instead.
return [path.dirname(target)];
}
}

const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
launch: ProcessLaunch,
onError: (cause: unknown) => ExternalLauncherError,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,7 @@ const makeWsRpcLayer = (
},
settings,
shellResumeCompletionMarker: true,
shellRevealInFileManager: true,
Comment thread
t3-code[bot] marked this conversation as resolved.
Outdated
threadResumeCompletionMarker: true,
threadSnapshotPagination: true,
};
Expand Down
106 changes: 103 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { remarkGithubAlerts } from "../markdown-github-alerts";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
import { PierreEntryIcon } from "./chat/PierreEntryIcon";
import { revealInFileExplorerLabelForOs } from "./preview/fileExplorerLabel";
import {
resolveExternalWebLinkHost,
showExternalLinkContextMenu,
Expand All @@ -59,7 +60,10 @@ import { ScrollArea } from "./ui/scroll-area";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { recordVisitForThread } from "../browserHistoryStore";
import { useOpenInPreferredEditor } from "../editorPreferences";
import {
PreferredEditorEnvironmentRequiredError,
useOpenInPreferredEditor,
} from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
Expand All @@ -85,6 +89,7 @@ import { cn } from "../lib/utils";
import { useRightPanelStore } from "../rightPanelStore";
import { useActiveEnvironmentId } 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 @@ -792,6 +797,10 @@ interface MarkdownFileLinkProps {
threadRef?: ScopedThreadRef | undefined;
onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
/** Platform-specific menu label ("Reveal in Finder", ...); required for the
reveal item to show. */
revealLabel?: string | undefined;
className?: string | undefined;
}

Expand Down Expand Up @@ -1094,6 +1103,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
threadRef,
onOpen,
onOpenInBrowser,
onReveal,
revealLabel,
className,
}: MarkdownFileLinkProps) {
const handleOpenInEditor = useCallback(() => {
Expand Down Expand Up @@ -1177,6 +1188,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
})();
}, [onOpenInBrowser, targetPath]);

const handleRevealInFileManager = useCallback(() => {
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) {
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(
(value: string, title: string) => {
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
Expand Down Expand Up @@ -1231,6 +1280,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
...(onOpenInBrowser
? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
: []),
...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []),
{ id: "copy-relative", label: "Copy relative path" },
{ id: "copy-full", label: "Copy full path" },
] as const,
Expand All @@ -1245,6 +1295,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
handleOpenInBrowser();
return;
}
if (clicked === "reveal") {
handleRevealInFileManager();
return;
}
if (clicked === "copy-relative") {
handleCopy(displayPath, "Relative path");
return;
Expand All @@ -1259,7 +1313,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
);
}
},
[displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath],
[
displayPath,
handleCopy,
handleOpenInBrowser,
handleOpenInEditor,
handleRevealInFileManager,
onOpenInBrowser,
onReveal,
revealLabel,
targetPath,
],
);

return (
Expand Down Expand Up @@ -1314,6 +1378,8 @@ function areMarkdownFileLinkPropsEqual(
previous.threadRef === next.threadRef &&
previous.onOpen === next.onOpen &&
previous.onOpenInBrowser === next.onOpenInBrowser &&
previous.onReveal === next.onReveal &&
previous.revealLabel === next.revealLabel &&
previous.className === next.className
);
}
Expand All @@ -1336,12 +1402,38 @@ function ChatMarkdown({
reportFailure: false,
});
const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);
const environmentId = useActiveEnvironmentId();
const activeEnvironmentId = useActiveEnvironmentId();
const environmentId = threadRef?.environmentId ?? activeEnvironmentId;
Comment thread
t3-code[bot] marked this conversation as resolved.
Outdated
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
const openInPreferredEditor = useOpenInPreferredEditor(
environmentId,
serverConfig?.availableEditors ?? [],
);
const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
reportFailure: false,
});
const revealInFileManagerLabel =
environmentId !== null &&
serverConfig?.shellRevealInFileManager === true &&
serverConfig.availableEditors.includes("file-manager")
? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os)
: undefined;
const revealFileInFileManager = useCallback(
(filePath: string) => {
if (environmentId === null) {
return Promise.resolve(
AsyncResult.failure<void, PreferredEditorEnvironmentRequiredError>(
Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })),
),
);
}
return openInEditor({
environmentId,
input: { cwd: filePath, editor: "file-manager", reveal: true },
});
},
[environmentId, openInEditor],
);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
Expand Down Expand Up @@ -1467,6 +1559,12 @@ function ChatMarkdown({
theme={resolvedTheme}
threadRef={threadRef}
onOpen={openInPreferredEditor}
onReveal={
revealInFileManagerLabel !== undefined
? () => revealFileInFileManager(fileLinkMeta.filePath)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
: undefined
}
revealLabel={revealInFileManagerLabel}
onOpenInBrowser={
threadRef &&
isPreviewSupportedInRuntime() &&
Expand Down Expand Up @@ -1690,6 +1788,8 @@ function ChatMarkdown({
openExternalLinkInPreview,
openMarkdownFileInPreview,
resolvedTheme,
revealFileInFileManager,
revealInFileManagerLabel,
skills,
text,
threadRef,
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/preview/fileExplorerLabel.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { revealInFileExplorerLabel } from "./fileExplorerLabel";
import { revealInFileExplorerLabel, revealInFileExplorerLabelForOs } from "./fileExplorerLabel";

describe("revealInFileExplorerLabel", () => {
it.each([
Expand All @@ -11,3 +11,14 @@ describe("revealInFileExplorerLabel", () => {
expect(revealInFileExplorerLabel(platform)).toBe(expected);
});
});

describe("revealInFileExplorerLabelForOs", () => {
it.each([
["darwin", "Reveal in Finder"],
["windows", "Reveal in File Explorer"],
["linux", "Reveal in Files"],
["unknown", "Reveal in Files"],
] as const)("maps %s to %s", (os, expected) => {
expect(revealInFileExplorerLabelForOs(os)).toBe(expected);
});
});
Loading
Loading