authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 15:43:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 15:56:40-08:00
log5c03804bac20c305159c2a42c6da7126166e8960
treeae63bde9e3838347fe7e8cc29b97d4497511e96d
parentd6ed393255c206008897529e8c006a40a9199a32

Improve changed-files tree UX and harden attachment path handling

- Compact single-directory chains in turn diff trees and preserve branch points - Add per-turn expand/collapse-all controls and themed VS Code file icons in ChatView - Centralize attachment route prefix stripping and tighten image/extension normalization helpers

14 files changed, 203 insertions(+), 55 deletions(-)

apps/server/src/attachmentPaths.ts+8
......@@ -2,6 +2,14 @@ import path from "node:path";
22
33export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
44
5export function stripAttachmentRoutePrefix(pathname: string): string | null {
6 if (!pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
7 return null;
8 }
9 const remainder = pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
10 return remainder.startsWith("/") ? remainder.slice(1) : remainder;
11}
12
513export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
614 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
715 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
apps/server/src/attachmentStore.ts+1
......@@ -14,6 +14,7 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
1414const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
1515const ATTACHMENT_ID_UUID_PATTERN =
1616 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
17// Keep this regex strict so attachment IDs cannot be used as path traversal vectors.
1718const ATTACHMENT_ID_PATTERN = new RegExp(
1819 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
1920 "i",
apps/server/src/config.ts+4-1
......@@ -9,9 +9,12 @@
99import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1010
1111export const DEFAULT_PORT = 3773;
12export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const;
1213
1314export type RuntimeMode = "web" | "desktop";
1415
16export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop";
17
1518/**
1619 * ServerConfigShape - Process/runtime configuration required by the server.
1720 */
......@@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
5053 port: 0,
5154 host: undefined,
5255 authToken: undefined,
53 keybindingsConfigPath: path.join(statedir, "keybindings.json"),
56 keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME),
5457 staticDir: undefined,
5558 devUrl: undefined,
5659 noBrowser: false,
apps/server/src/git/Errors.ts+2
......@@ -59,6 +59,8 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(
5959
6060/**
6161 * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
62 *
63 * Keep this union centralized so callers can pattern-match once at API boundaries.
6264 */
6365export type GitManagerServiceError =
6466 | GitManagerError
apps/server/src/git/Services/GitManager.ts+2
......@@ -38,6 +38,8 @@ export interface GitManagerShape {
3838
3939/**
4040 * GitManager - Service tag for stacked Git workflow orchestration.
41 *
42 * Single tag keeps orchestration wiring swappable in Layer-based tests.
4143 */
4244export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(
4345 "t3/git/Services/GitManager",
apps/server/src/git/Services/TextGeneration.ts+2
......@@ -62,6 +62,8 @@ export interface TextGenerationService {
6262
6363/**
6464 * TextGenerationShape - Service API for commit/PR text generation.
65 *
66 * Interface isolates provider-specific model behavior behind deterministic method contracts.
6567 */
6668export interface TextGenerationShape {
6769 /**
apps/server/src/imageMime.ts+26-4
......@@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
2929 ".webp",
3030]);
3131
32const normalizeImageExtension = (extension: string): string | null => {
33 const trimmed = extension.trim();
34 if (trimmed.length === 0) {
35 return null;
36 }
37
38 const lower = trimmed.toLowerCase();
39 return lower.startsWith(".") ? lower : `.${lower}`;
40};
41
42const coerceSafeImageExtension = (
43 extension: string | null | undefined,
44): string | null => {
45 if (!extension) return null;
46 const normalized = normalizeImageExtension(extension);
47 if (!normalized) return null;
48 return SAFE_IMAGE_FILE_EXTENSIONS.has(normalized) ? normalized : null;
49};
50
51export const isSafeImageExtension = (extension: string): boolean =>
52 coerceSafeImageExtension(extension) !== null;
53
3254export function parseBase64DataUrl(
3355 dataUrl: string,
3456): { readonly mimeType: string; readonly base64: string } | null {
......@@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string
6385 return fromMime;
6486 }
6587
66 const fromMimeExtension = Mime.getExtension(input.mimeType);
67 if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {
88 const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType));
89 if (fromMimeExtension) {
6890 return fromMimeExtension;
6991 }
7092
7193 const fileName = input.fileName?.trim() ?? "";
7294 const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
73 const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
74 if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
95 const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null;
96 if (fileNameExtension) {
7597 return fileNameExtension;
7698 }
7799
apps/server/src/index.ts+1
......@@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" };
1010import { ServerLive } from "./wsServer";
1111import { NetService } from "@t3tools/shared/Net";
1212
13// Compose all required services once so every CLI invocation shares the same runtime.
1314const RuntimeLayer = Layer.empty.pipe(
1415 Layer.provideMerge(CliConfig.layer),
1516 Layer.provideMerge(ServerLive),
apps/server/src/main.ts+11-7
......@@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net";
1212
1313import {
1414 DEFAULT_PORT,
15 KEYBINDINGS_CONFIG_FILENAME,
1516 resolveStaticDir,
1617 ServerConfig,
18 isDesktopMode,
1719 type RuntimeMode,
1820 type ServerConfigShape,
1921} from "./config";
......@@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) =>
133135 );
134136
135137 const mode = Option.getOrElse(input.mode, () => env.mode);
138 const isDesktop = isDesktopMode(mode);
139 const isWeb = !isDesktop;
136140
137141 const port = yield* Option.match(input.port, {
138142 onSome: (value) => Effect.succeed(value),
......@@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) =>
140144 if (env.port) {
141145 return Effect.succeed(env.port);
142146 }
143 if (mode === "desktop") {
147 if (isDesktop) {
144148 return Effect.succeed(DEFAULT_PORT);
145149 }
146150 return findAvailablePort(DEFAULT_PORT);
......@@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) =>
153157 const noBrowser = Option.match(input.noBrowser, {
154158 // effect/cli boolean flags parse to `false` when absent; in that case
155159 // we still want env/mode fallbacks to apply.
156 onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")),
157 onNone: () => env.noBrowser ?? mode === "desktop",
160 onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)),
161 onNone: () => env.noBrowser ?? isDesktop,
158162 });
159163 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
160164 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {
161165 // effect/cli boolean flags parse to `false` when absent; in that case
162166 // we still want env/mode fallbacks to apply.
163 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")),
164 onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web",
167 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)),
168 onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb,
165169 });
166170 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {
167171 // effect/cli boolean flags parse to `false` when absent; in that case
......@@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) =>
171175 });
172176 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
173177 const { join } = yield* Path.Path;
174 const keybindingsConfigPath = join(stateDir, "keybindings.json");
178 const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME);
175179 const host =
176180 Option.getOrUndefined(input.host) ??
177181 env.host ??
178 (mode === "desktop" ? "127.0.0.1" : undefined);
182 (isDesktop ? "127.0.0.1" : undefined);
179183
180184 return {
181185 mode,
apps/server/src/os-jank.ts+1
......@@ -3,6 +3,7 @@ import { Effect, Path } from "effect";
33import { execFileSync } from "node:child_process";
44
55export function fixPath(): void {
6 // Only macOS shells need PATH hydration from interactive startup files.
67 if (process.platform !== "darwin") return;
78
89 try {
apps/server/src/wsServer.ts+4-4
......@@ -60,9 +60,9 @@ import { ServerConfig } from "./config";
6060import { GitCore } from "./git/Services/GitCore.ts";
6161import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
6262import {
63 ATTACHMENTS_ROUTE_PREFIX,
6463 normalizeAttachmentRelativePath,
6564 resolveAttachmentRelativePath,
65 stripAttachmentRoutePrefix,
6666} from "./attachmentPaths";
6767import {
6868 createAttachmentId,
......@@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
350350 return;
351351 }
352352
353 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
354 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
355 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
353 const routeRelativePath = stripAttachmentRoutePrefix(url.pathname);
354 if (routeRelativePath !== null) {
355 const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath);
356356 if (!normalizedRelativePath) {
357357 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
358358 return;
apps/web/src/components/ChatView.tsx+77-22
......@@ -340,6 +340,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
340340 pathValue: string;
341341 kind: "file" | "directory";
342342 theme: "light" | "dark";
343 className?: string;
343344}) {
344345 const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);
345346 const iconUrl = useMemo(
......@@ -350,9 +351,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
350351
351352 if (failed) {
352353 return props.kind === "directory" ? (
353 <FolderIcon className="size-4 text-muted-foreground/80" />
354 <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
354355 ) : (
355 <FileIcon className="size-4 text-muted-foreground/80" />
356 <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
356357 );
357358 }
358359
......@@ -361,7 +362,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
361362 src={iconUrl}
362363 alt=""
363364 aria-hidden="true"
364 className="size-4 shrink-0"
365 className={cn("size-4 shrink-0", props.className)}
365366 loading="lazy"
366367 onError={() => setFailedIconUrl(iconUrl)}
367368 />
......@@ -2554,6 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25542555 isRevertingCheckpoint={isRevertingCheckpoint}
25552556 onImageExpand={onExpandTimelineImage}
25562557 markdownCwd={gitCwd ?? undefined}
2558 resolvedTheme={resolvedTheme}
25572559 />
25582560 </div>
25592561
......@@ -3154,14 +3156,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: {
31543156 );
31553157});
31563158
3157function buildInitiallyExpandedDirectoryState(
3158 nodes: ReadonlyArray<TurnDiffTreeNode>,
3159function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
3160 const paths: string[] = [];
3161 for (const node of nodes) {
3162 if (node.kind !== "directory") continue;
3163 paths.push(node.path);
3164 paths.push(...collectDirectoryPaths(node.children));
3165 }
3166 return paths;
3167}
3168
3169function buildDirectoryExpansionState(
3170 directoryPaths: ReadonlyArray<string>,
3171 expanded: boolean,
31593172): Record<string, boolean> {
31603173 const expandedState: Record<string, boolean> = {};
3161 for (const node of nodes) {
3162 if (node.kind === "directory") {
3163 expandedState[node.path] = true;
3164 }
3174 for (const directoryPath of directoryPaths) {
3175 expandedState[directoryPath] = expanded;
31653176 }
31663177 return expandedState;
31673178}
......@@ -3169,13 +3180,27 @@ function buildInitiallyExpandedDirectoryState(
31693180const ChangedFilesTree = memo(function ChangedFilesTree(props: {
31703181 turnId: TurnId;
31713182 files: ReadonlyArray<TurnDiffFileChange>;
3183 allDirectoriesExpanded: boolean;
3184 resolvedTheme: "light" | "dark";
31723185 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
31733186}) {
3174 const { files, onOpenTurnDiff, turnId } = props;
3187 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;
31753188 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
3189 const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]);
3190 const allDirectoryExpansionState = useMemo(
3191 () =>
3192 buildDirectoryExpansionState(
3193 directoryPathsKey ? directoryPathsKey.split("\u0000") : [],
3194 allDirectoriesExpanded,
3195 ),
3196 [allDirectoriesExpanded, directoryPathsKey],
3197 );
31763198 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(
3177 () => buildInitiallyExpandedDirectoryState(treeNodes),
3199 () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true),
31783200 );
3201 useEffect(() => {
3202 setExpandedDirectories(allDirectoryExpansionState);
3203 }, [allDirectoryExpansionState]);
31793204
31803205 const toggleDirectory = useCallback((pathValue: string) => {
31813206 setExpandedDirectories((current) => ({
......@@ -3233,7 +3258,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
32333258 onClick={() => onOpenTurnDiff(turnId, node.path)}
32343259 >
32353260 <span aria-hidden="true" className="size-3.5 shrink-0" />
3236 <FileIcon className="size-3.5 shrink-0 text-muted-foreground/70" />
3261 <VscodeEntryIcon
3262 pathValue={node.path}
3263 kind="file"
3264 theme={resolvedTheme}
3265 className="size-3.5 text-muted-foreground/70"
3266 />
32373267 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
32383268 {node.name}
32393269 </span>
......@@ -3272,6 +3302,7 @@ interface MessagesTimelineProps {
32723302 isRevertingCheckpoint: boolean;
32733303 onImageExpand: (preview: ExpandedImagePreview) => void;
32743304 markdownCwd: string | undefined;
3305 resolvedTheme: "light" | "dark";
32753306}
32763307
32773308type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
......@@ -3312,6 +3343,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
33123343 isRevertingCheckpoint,
33133344 onImageExpand,
33143345 markdownCwd,
3346 resolvedTheme,
33153347}: MessagesTimelineProps) {
33163348 const timelineRootRef = useRef<HTMLDivElement | null>(null);
33173349 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
......@@ -3484,6 +3516,15 @@ const MessagesTimeline = memo(function MessagesTimeline({
34843516
34853517 const virtualRows = rowVirtualizer.getVirtualItems();
34863518 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
3519 const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
3520 Record<string, boolean>
3521 >({});
3522 const onToggleAllDirectories = useCallback((turnId: TurnId) => {
3523 setAllDirectoriesExpandedByTurnId((current) => ({
3524 ...current,
3525 [turnId]: !(current[turnId] ?? true),
3526 }));
3527 }, []);
34873528
34883529 const renderRowContent = (row: TimelineRow) => (
34893530 <div
......@@ -3659,6 +3700,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
36593700 if (checkpointFiles.length === 0) return null;
36603701 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
36613702 const changedFileCountLabel = String(checkpointFiles.length);
3703 const allDirectoriesExpanded =
3704 allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;
36623705 return (
36633706 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
36643707 <div className="mb-1.5 flex items-center justify-between gap-2">
......@@ -3674,21 +3717,33 @@ const MessagesTimeline = memo(function MessagesTimeline({
36743717 </>
36753718 )}
36763719 </p>
3677 <Button
3678 type="button"
3679 size="xs"
3680 variant="outline"
3681 onClick={() =>
3682 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
3683 }
3684 >
3685 View diff
3686 </Button>
3720 <div className="flex items-center gap-1.5">
3721 <Button
3722 type="button"
3723 size="xs"
3724 variant="outline"
3725 onClick={() => onToggleAllDirectories(turnSummary.turnId)}
3726 >
3727 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
3728 </Button>
3729 <Button
3730 type="button"
3731 size="xs"
3732 variant="outline"
3733 onClick={() =>
3734 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
3735 }
3736 >
3737 View diff
3738 </Button>
3739 </div>
36873740 </div>
36883741 <ChangedFilesTree
36893742 key={`changed-files-tree:${turnSummary.turnId}`}
36903743 turnId={turnSummary.turnId}
36913744 files={checkpointFiles}
3745 allDirectoriesExpanded={allDirectoriesExpanded}
3746 resolvedTheme={resolvedTheme}
36923747 onOpenTurnDiff={onOpenTurnDiff}
36933748 />
36943749 </div>
apps/web/src/lib/turnDiffTree.test.ts+37-15
......@@ -96,32 +96,54 @@ describe("buildTurnDiffTree", () => {
9696 expect(tree).toEqual([
9797 {
9898 kind: "directory",
99 name: "apps",
100 path: "apps",
99 name: "apps/web/src",
100 path: "apps/web/src",
101101 stat: { additions: 2, deletions: 1 },
102 children: [
103 {
104 kind: "file",
105 name: "index.ts",
106 path: "apps/web/src/index.ts",
107 stat: { additions: 2, deletions: 1 },
108 },
109 ],
110 },
111 ]);
112 });
113
114 it("compacts only single-directory chains and stops at branch points", () => {
115 const tree = buildTurnDiffTree([
116 { path: "apps/server/src/index.ts", additions: 2, deletions: 1 },
117 { path: "apps/server/main.ts", additions: 4, deletions: 0 },
118 ]);
119
120 expect(tree).toEqual([
121 {
122 kind: "directory",
123 name: "apps/server",
124 path: "apps/server",
125 stat: { additions: 6, deletions: 1 },
102126 children: [
103127 {
104128 kind: "directory",
105 name: "web",
106 path: "apps/web",
129 name: "src",
130 path: "apps/server/src",
107131 stat: { additions: 2, deletions: 1 },
108132 children: [
109133 {
110 kind: "directory",
111 name: "src",
112 path: "apps/web/src",
134 kind: "file",
135 name: "index.ts",
136 path: "apps/server/src/index.ts",
113137 stat: { additions: 2, deletions: 1 },
114 children: [
115 {
116 kind: "file",
117 name: "index.ts",
118 path: "apps/web/src/index.ts",
119 stat: { additions: 2, deletions: 1 },
120 },
121 ],
122138 },
123139 ],
124140 },
141 {
142 kind: "file",
143 name: "main.ts",
144 path: "apps/server/main.ts",
145 stat: { additions: 4, deletions: 0 },
146 },
125147 ],
126148 },
127149 ]);
apps/web/src/lib/turnDiffTree.ts+27-2
......@@ -54,10 +54,34 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null {
5454 };
5555}
5656
57function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode {
58 const compactedChildren = node.children.map((child) =>
59 child.kind === "directory" ? compactDirectoryNode(child) : child,
60 );
61
62 let compactedNode: TurnDiffTreeDirectoryNode = {
63 ...node,
64 children: compactedChildren,
65 };
66
67 while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") {
68 const onlyChild = compactedNode.children[0];
69 compactedNode = {
70 kind: "directory",
71 name: `${compactedNode.name}/${onlyChild.name}`,
72 path: onlyChild.path,
73 stat: onlyChild.stat,
74 children: onlyChild.children,
75 };
76 }
77
78 return compactedNode;
79}
80
5781function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
5882 const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values())
5983 .toSorted(compareByName)
60 .map((subdirectory) => ({
84 .map<TurnDiffTreeDirectoryNode>((subdirectory) => ({
6185 kind: "directory",
6286 name: subdirectory.name,
6387 path: subdirectory.path,
......@@ -66,7 +90,8 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
6690 deletions: subdirectory.stat.deletions,
6791 },
6892 children: toTreeNodes(subdirectory),
69 }));
93 }))
94 .map((subdirectory) => compactDirectoryNode(subdirectory));
7095
7196 const files = directory.files.toSorted(compareByName);
7297 return [...subdirectories, ...files];