diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx
index bbdbc28b06..c66ab9a1cf 100644
--- a/apps/web/src/components/CommandPaletteResults.tsx
+++ b/apps/web/src/components/CommandPaletteResults.tsx
@@ -14,71 +14,9 @@ import {
CommandList,
CommandShortcut,
} from "./ui/command";
+import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
import { cn } from "~/lib/utils";
-function foldAsciiCase(value: string): string {
- return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
-}
-
-function HighlightedSearchText(props: { text: string; query: string }) {
- const query = props.query.trim();
- if (query.length === 0) return props.text;
-
- const normalizedText = foldAsciiCase(props.text);
- const normalizedQuery = foldAsciiCase(query);
- const parts: Array<{
- readonly text: string;
- readonly highlighted: boolean;
- readonly start: number;
- }> = [];
- let cursor = 0;
-
- while (cursor < props.text.length) {
- const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
- if (matchIndex === -1) {
- parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
- break;
- }
- if (matchIndex > cursor) {
- parts.push({
- text: props.text.slice(cursor, matchIndex),
- highlighted: false,
- start: cursor,
- });
- }
- parts.push({
- text: props.text.slice(matchIndex, matchIndex + query.length),
- highlighted: true,
- start: matchIndex,
- });
- cursor = matchIndex + query.length;
- }
-
- return parts.map((part) =>
- part.highlighted ? (
-
- {part.text}
-
- ) : (
- part.text
- ),
- );
-}
-
-function ThreadContentMatch(props: {
- match: NonNullable;
-}) {
- const isUser = props.match.source === "user";
- return (
-
-
- {isUser ? "You:" : "Agent:"}
- {" "}
-
-
- );
-}
-
interface CommandPaletteResultsProps {
emptyStateMessage?: string;
groups: ReadonlyArray;
@@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: {
{props.item.title}
{props.item.threadContentMatch ? (
-
+
) : null}
{props.item.description ? (
@@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: {
{props.item.title}
{props.item.threadContentMatch ? (
-
+
) : null}
{props.item.description ? (
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index 31c0cf407c..8865a23b6d 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -52,6 +52,7 @@ import {
type SidebarSection,
resolveSidebarDropVerb,
} from "./Sidebar.logic";
+import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
EnvironmentId,
OrchestrationLatestTurn,
@@ -898,11 +899,23 @@ describe("resolveSidebarThreadStatus", () => {
});
describe("searchSidebarThreads", () => {
+ const searchThread = (id: string, title: string, project: string) => ({
+ environmentId: localEnvironmentId,
+ id: ThreadId.make(id),
+ title,
+ project,
+ });
const threads = [
- { id: "thread-1", title: "Fix workspace search", project: "Alpha" },
- { id: "thread-2", title: "Review providers", project: "Workspace" },
- { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" },
+ searchThread("thread-1", "Fix workspace search", "Alpha"),
+ searchThread("thread-2", "Review providers", "Workspace"),
+ searchThread("thread-3", "WORKTREE cleanup", "Beta"),
];
+ const contentKeys = (...ids: ReadonlyArray) =>
+ new Set(
+ ids.map((id) =>
+ threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }),
+ ),
+ );
it("matches thread titles case-insensitively and preserves their order", () => {
expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
@@ -915,6 +928,28 @@ describe("searchSidebarThreads", () => {
it("returns no results for an empty query", () => {
expect(searchSidebarThreads(threads, " ")).toEqual([]);
});
+
+ it("appends content-only matches after every title match", () => {
+ expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([
+ threads[0],
+ threads[2],
+ threads[1],
+ ]);
+ });
+
+ it("lists a thread matching both title and content once", () => {
+ expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([
+ threads[0],
+ threads[2],
+ ]);
+ });
+
+ it("ignores content matches for threads outside the sidebar collection", () => {
+ expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([
+ threads[0],
+ threads[2],
+ ]);
+ });
});
describe("filterSidebarProjectScopeItems", () => {
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 9c7c5ab56c..6edc0d4bac 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -5,7 +5,13 @@ import {
isAtomCommandInterrupted,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
-import type { ContextMenuItem, OrchestrationThreadShell } from "@t3tools/contracts";
+import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
+import type {
+ ContextMenuItem,
+ EnvironmentId,
+ OrchestrationThreadShell,
+ ThreadId,
+} from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/unstable/reactivity";
import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
@@ -942,21 +948,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c
export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
+const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set();
+
/**
- * Search the already-ordered sidebar thread collection by title or linked PR.
- * Keeping the input order means lifecycle ordering (active, snoozed, settled)
- * remains stable while the user narrows the list.
+ * Search the already-ordered sidebar thread collection by title or linked PR,
+ * plus any thread whose messages the server matched (`contentMatchKeys`, keyed
+ * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering
+ * (active, snoozed, settled) remains stable while the user narrows the list.
*/
export function searchSidebarThreads<
- T extends { readonly title: string } & Parameters[0],
->(threads: readonly T[], query: string): T[] {
+ T extends {
+ readonly environmentId: EnvironmentId;
+ readonly id: ThreadId;
+ readonly title: string;
+ } & Parameters[0],
+>(
+ threads: readonly T[],
+ query: string,
+ contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS,
+): T[] {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) return [];
- return threads.filter((thread) =>
- [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
+ const titleMatches: T[] = [];
+ const contentMatches: T[] = [];
+ for (const thread of threads) {
+ const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
term.toLowerCase().includes(normalizedQuery),
- ),
- );
+ );
+ if (matchesTitle) {
+ titleMatches.push(thread);
+ } else if (
+ contentMatchKeys.size > 0 &&
+ contentMatchKeys.has(
+ threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
+ )
+ ) {
+ contentMatches.push(thread);
+ }
+ }
+ return [...titleMatches, ...contentMatches];
}
export function filterSidebarProjectScopeItems(input: {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index e1e5473df0..ffb803fd0b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -28,6 +28,10 @@ import {
nestDelegatedThreads,
nestedRowContainsThread,
} from "@t3tools/client-runtime/state/delegated-threads";
+import {
+ threadSearchMatchKey,
+ type EnvironmentThreadSearchMatch,
+} from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import {
parseScopedThreadKey,
@@ -138,6 +142,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s
import { vcsEnvironment } from "../state/vcs";
import { threadEnvironment } from "../state/threads";
import { useEnvironmentQuery } from "../state/query";
+import { useThreadSearch } from "../state/queries";
import { useAtomCommand } from "../state/use-atom-command";
import {
buildThreadRouteParams,
@@ -224,6 +229,7 @@ import {
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
+import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
@@ -2107,6 +2113,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
isHighlighted: boolean;
isRouteActive: boolean;
resultId: string;
+ searchMatch: EnvironmentThreadSearchMatch | null;
+ searchQuery: string;
onHighlight: () => void;
onSelect: () => void;
onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
@@ -2196,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
onMouseMove={props.onHighlight}
onClick={props.onSelect}
className={cn(
- "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
+ "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none",
props.isHighlighted || props.isRouteActive
? "bg-sidebar-row-active text-sidebar-foreground"
: "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
@@ -2209,9 +2217,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
{props.project ? (
) : null}
- {thread.title}
-
- {threadTimeLabel(thread)}
+
+
+ {thread.title}
+
+ {threadTimeLabel(thread)}
+
+
+ {props.searchMatch ? (
+
+ ) : null}
[...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
[activeThreads, pinnedThreads, settledThreads, snoozedThreads],
);
+ const searchEnvironmentIds = useMemo(
+ () =>
+ environments
+ .filter((environment) => environment.connection.phase === "connected")
+ .map((environment) => environment.environmentId),
+ [environments],
+ );
+ // useThreadSearch owns the debounce and the two-character floor.
+ const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery);
+ const threadSearchMatchByKey = useMemo(
+ () =>
+ new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)),
+ [threadSearch.matches],
+ );
const threadSearchResults = useMemo(
- () => searchSidebarThreads(searchableThreads, threadSearchQuery),
- [searchableThreads, threadSearchQuery],
+ () =>
+ searchSidebarThreads(
+ searchableThreads,
+ threadSearchQuery,
+ new Set(threadSearchMatchByKey.keys()),
+ ),
+ [searchableThreads, threadSearchQuery, threadSearchMatchByKey],
);
const threadSearchResultOrderKey = threadSearchResults
.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
@@ -4762,6 +4802,15 @@ export default function Sidebar() {
isHighlighted={activeSearchResultIndex === index}
isRouteActive={routeThreadKey === threadKey}
resultId={`sidebar-thread-search-result-${index}`}
+ searchMatch={
+ threadSearchMatchByKey.get(
+ threadSearchMatchKey({
+ environmentId: thread.environmentId,
+ threadId: thread.id,
+ }),
+ ) ?? null
+ }
+ searchQuery={threadSearchQuery}
onHighlight={() => setActiveSearchResultIndex(index)}
onSelect={() => selectThreadSearchResult(thread)}
onFileDropThreads={handleThreadFileDrop}
@@ -4775,7 +4824,7 @@ export default function Sidebar() {
role="status"
className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
>
- No threads found
+ {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}
)
) : null}
diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx
new file mode 100644
index 0000000000..db2e286ae2
--- /dev/null
+++ b/apps/web/src/components/ThreadSearchMatch.tsx
@@ -0,0 +1,66 @@
+function foldAsciiCase(value: string): string {
+ return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
+}
+
+function HighlightedSearchText(props: { text: string; query: string }) {
+ const query = props.query.trim();
+ if (query.length === 0) return props.text;
+
+ const normalizedText = foldAsciiCase(props.text);
+ const normalizedQuery = foldAsciiCase(query);
+ const parts: Array<{
+ readonly text: string;
+ readonly highlighted: boolean;
+ readonly start: number;
+ }> = [];
+ let cursor = 0;
+
+ while (cursor < props.text.length) {
+ const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
+ if (matchIndex === -1) {
+ parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
+ break;
+ }
+ if (matchIndex > cursor) {
+ parts.push({
+ text: props.text.slice(cursor, matchIndex),
+ highlighted: false,
+ start: cursor,
+ });
+ }
+ parts.push({
+ text: props.text.slice(matchIndex, matchIndex + query.length),
+ highlighted: true,
+ start: matchIndex,
+ });
+ cursor = matchIndex + query.length;
+ }
+
+ return parts.map((part) =>
+ part.highlighted ? (
+
+ {part.text}
+
+ ) : (
+ part.text
+ ),
+ );
+}
+
+export function ThreadSearchMatchExcerpt(props: {
+ match: {
+ readonly source: "user" | "assistant";
+ readonly snippet: string;
+ readonly query: string;
+ };
+}) {
+ const isUser = props.match.source === "user";
+ return (
+
+
+ {isUser ? "You:" : "Agent:"}
+ {" "}
+
+
+ );
+}
diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx
index d7767ab942..cd8f4d5b32 100644
--- a/apps/web/src/components/diffs/DiffFileTree.test.tsx
+++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx
@@ -5,7 +5,10 @@ import { act, type MouseEvent, type ReactNode } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import type { FileDiffMetadata } from "@pierre/diffs";
+
import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree";
+import { diffFileTreeEntries } from "./diffFileTree.logic";
import { useCodeViewFileReveal } from "./useCodeViewFileReveal";
vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
@@ -112,6 +115,22 @@ describe("diff tree file activation", () => {
vi.unstubAllGlobals();
});
+ it("mounts a file-to-symlink type change as one tree row", async () => {
+ // Git carries a type change as a deletion and an addition of the same path.
+ const typeChange = diffFileTreeEntries([
+ { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata,
+ { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata,
+ { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata,
+ ]);
+ await mount({ files: [...typeChange] });
+
+ const tree = model();
+ expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false);
+ expect(tree.getItem("CLAUDE.md")?.isDirectory()).toBe(false);
+ await activate("AGENTS.md");
+ expect(targets).toEqual([{ type: "item", id: "AGENTS.md\u0000AGENTS.md", align: "start" }]);
+ });
+
it("reissues the reveal when the sole selected file is activated again", async () => {
await mount();
await activate("02-short.ts");
diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts
index 4aa1fd7208..170e843080 100644
--- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts
+++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts
@@ -34,6 +34,23 @@ describe("diffFileTreeEntries", () => {
});
});
+describe("diffFileTreeEntries", () => {
+ it("folds a file-to-symlink type change into one modified entry", () => {
+ expect(
+ diffFileTreeEntries([
+ file("change", "CLAUDE.md"),
+ file("deleted", "AGENTS.md"),
+ file("new", "AGENTS.md"),
+ file("new", "docs/new.md"),
+ ]),
+ ).toEqual([
+ { path: "CLAUDE.md", status: "modified" },
+ { path: "AGENTS.md", status: "modified" },
+ { path: "docs/new.md", status: "added" },
+ ]);
+ });
+});
+
describe("collectDirectoryPaths", () => {
it("lists every ancestor once, parents first, with Pierre's trailing slash", () => {
expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([
diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts
index 7331fb150e..b727c3ee3f 100644
--- a/apps/web/src/components/diffs/diffFileTree.logic.ts
+++ b/apps/web/src/components/diffs/diffFileTree.logic.ts
@@ -23,11 +23,22 @@ function toGitStatus(file: FileDiffMetadata): GitStatus {
}
}
-/** Maps parsed diff files to tree entries, keeping the diff's own order. */
+/**
+ * Maps parsed diff files to tree entries, keeping the diff's own order. A path
+ * appears once: a type change (regular file to symlink) is a deletion plus an
+ * addition of the same path, and the tree shows the surviving file as modified.
+ */
export function diffFileTreeEntries(
files: ReadonlyArray,
): ReadonlyArray {
- return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) }));
+ const statusByPath = new Map();
+ for (const file of files) {
+ const path = resolveFileDiffPath(file);
+ const status = toGitStatus(file);
+ const previous = statusByPath.get(path);
+ statusByPath.set(path, previous === undefined || previous === status ? status : "modified");
+ }
+ return [...statusByPath].map(([path, status]) => ({ path, status }));
}
/**
diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx
index 1fb8204790..1952fc7c3b 100644
--- a/apps/web/src/components/files/FileBrowserPanel.tsx
+++ b/apps/web/src/components/files/FileBrowserPanel.tsx
@@ -30,7 +30,7 @@ interface FileBrowserPanelProps {
environmentId: EnvironmentId;
cwd: string;
projectName: string;
- /** File currently open in the preview pane; revealed and selected in the tree. */
+ /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */
selectedPath: string | null;
/** Bumped when the same path should be revealed again (e.g. re-opened from search). */
selectedPathRevealId: number;
@@ -298,8 +298,13 @@ export default function FileBrowserPanel({
) {
return;
}
- if (entryKinds.get(selectedPath) !== "file") return;
- const selectedItem = model.getItem(selectedPath);
+ const selectedKind = entryKinds.get(selectedPath);
+ // An unloaded entry has no row to reveal yet; folders do, and chat links can
+ // point at them.
+ if (selectedKind === undefined) return;
+ // Directory rows are registered with a trailing slash (see treePath).
+ const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath;
+ const selectedItem = model.getItem(selectedTreePath);
if (!selectedItem) return;
// A selection that originated inside the tree (clicking a row, possibly
@@ -333,8 +338,12 @@ export default function FileBrowserPanel({
if (item && "expand" in item) item.expand();
}
+ if ("expand" in selectedItem) selectedItem.expand();
selectedItem.select();
- model.scrollToPath(selectedPath, { focus: true, offset: "center" });
+ model.scrollToPath(selectedTreePath, {
+ focus: true,
+ offset: "center",
+ });
queueMicrotask(() => {
syncingSelectionRef.current = false;
});
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index 98378065ff..4043159d76 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -941,15 +941,22 @@ export default function FilePreviewPanel({
// A file outside the workspace (an absolute path) is shown, never edited.
const isHostFile =
attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
- const file = useProjectFileQuery(
- environmentId,
- cwd,
- relativePath,
- attachment === undefined && !isMedia && !isPdf,
- );
+ // Media and PDFs render from their absolute path, so their contents are never
+ // shown. The read still runs: a folder named `assets.png` is only knowable as a
+ // folder from the read failure, and the server stats before reading, so a folder
+ // costs an open and a stat and returns no body.
+ const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined);
+ // A chat link cannot tell a folder from a file, so a folder arrives here as
+ // a file surface and the read fails. Keep the breadcrumbs, drop the preview
+ // pane, and let the tree fill the surface with the folder revealed. Mutation
+ // refresh stays on so the surface notices if the path becomes a file. A host
+ // path cannot be revealed in the workspace tree, so it keeps the read error.
+ const isDirectory = file.isNotFile && !isHostFile;
+ // Everything preview-related keys off previewPath; a folder has no preview.
+ const previewPath = isDirectory ? null : relativePath;
const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
const showExplorer = shouldShowFileExplorer({
- relativePath,
+ relativePath: previewPath,
explorerOpen,
attachmentOpen: attachment !== undefined,
});
@@ -977,9 +984,9 @@ export default function FilePreviewPanel({
null,
);
const breadcrumbRef = useRef(null);
- const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;
+ const isMarkdown = previewPath ? isMarkdownPreviewFile(previewPath) : false;
const tableDelimiter =
- relativePath && attachment === undefined ? filePreviewDelimiter({ name: relativePath }) : null;
+ previewPath && attachment === undefined ? filePreviewDelimiter({ name: previewPath }) : null;
// A reveal still wins over the preference: the line only exists in the source.
const revealHandled =
revealLine === null ||
@@ -994,7 +1001,8 @@ export default function FilePreviewPanel({
: isHtml
? ("html" as const)
: null;
- const canToggleRendered = attachment === undefined && renderedMode !== null;
+ const canToggleRendered =
+ previewPath !== null && attachment === undefined && renderedMode !== null;
const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile;
const setRenderedPreferred = isMarkdown
? setRenderMarkdownPreferred
@@ -1002,11 +1010,11 @@ export default function FilePreviewPanel({
? setRenderTablePreferred
: setRenderBrowserFilePreferred;
const canOpenInBrowser =
- relativePath !== null &&
+ previewPath !== null &&
attachment === undefined &&
!isVideo &&
isPreviewSupportedInRuntime() &&
- isBrowserPreviewFile(relativePath);
+ isBrowserPreviewFile(previewPath);
const absolutePath =
relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
@@ -1014,8 +1022,10 @@ export default function FilePreviewPanel({
enabled:
attachment === undefined &&
relativePath !== null &&
- !isMedia &&
- !isPdf &&
+ // Media and PDFs never show their contents, so re-reading them on every
+ // workspace mutation is waste. A folder named like one still re-reads, so
+ // it notices when the path becomes a file.
+ (isDirectory || (!isMedia && !isPdf)) &&
!selectedFilePending,
mutationId: workspaceMutationId,
refresh: file.refresh,
@@ -1127,7 +1137,7 @@ export default function FilePreviewPanel({
) : null}
- {!isHostFile ? (
+ {!isHostFile && previewPath !== null ? (
) : null}
- {relativePath &&
+ {previewPath &&
attachment === undefined &&
!isMedia &&
!renderBrowserFile &&
@@ -1149,12 +1159,9 @@ export default function FilePreviewPanel({
) : null}