| author | |
| committer | |
| log | 5c03804bac20c305159c2a42c6da7126166e8960 |
| tree | ae63bde9e3838347fe7e8cc29b97d4497511e96d |
| parent | d6ed393255c206008897529e8c006a40a9199a32 |
- 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 helpers14 files changed, 203 insertions(+), 55 deletions(-)
apps/server/src/attachmentPaths.ts+8| ... | ... | @@ -2,6 +2,14 @@ import path from "node:path"; |
| 2 | 2 | |
| 3 | 3 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; |
| 4 | 4 | |
| 5 | export 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 | ||
| 5 | 13 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { |
| 6 | 14 | const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 7 | 15 | 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; |
| 14 | 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 15 | 15 | const ATTACHMENT_ID_UUID_PATTERN = |
| 16 | 16 | "[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. | |
| 17 | 18 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 18 | 19 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, |
| 19 | 20 | "i", |
apps/server/src/config.ts+4-1| ... | ... | @@ -9,9 +9,12 @@ |
| 9 | 9 | import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; |
| 10 | 10 | |
| 11 | 11 | export const DEFAULT_PORT = 3773; |
| 12 | export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const; | |
| 12 | 13 | |
| 13 | 14 | export type RuntimeMode = "web" | "desktop"; |
| 14 | 15 | |
| 16 | export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop"; | |
| 17 | ||
| 15 | 18 | /** |
| 16 | 19 | * ServerConfigShape - Process/runtime configuration required by the server. |
| 17 | 20 | */ |
| ... | ... | @@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 50 | 53 | port: 0, |
| 51 | 54 | host: undefined, |
| 52 | 55 | authToken: undefined, |
| 53 | keybindingsConfigPath: path.join(statedir, "keybindings.json"), | |
| 56 | keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME), | |
| 54 | 57 | staticDir: undefined, |
| 55 | 58 | devUrl: undefined, |
| 56 | 59 | noBrowser: false, |
apps/server/src/git/Errors.ts+2| ... | ... | @@ -59,6 +59,8 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( |
| 59 | 59 | |
| 60 | 60 | /** |
| 61 | 61 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. |
| 62 | * | |
| 63 | * Keep this union centralized so callers can pattern-match once at API boundaries. | |
| 62 | 64 | */ |
| 63 | 65 | export type GitManagerServiceError = |
| 64 | 66 | | GitManagerError |
apps/server/src/git/Services/GitManager.ts+2| ... | ... | @@ -38,6 +38,8 @@ export interface GitManagerShape { |
| 38 | 38 | |
| 39 | 39 | /** |
| 40 | 40 | * GitManager - Service tag for stacked Git workflow orchestration. |
| 41 | * | |
| 42 | * Single tag keeps orchestration wiring swappable in Layer-based tests. | |
| 41 | 43 | */ |
| 42 | 44 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( |
| 43 | 45 | "t3/git/Services/GitManager", |
apps/server/src/git/Services/TextGeneration.ts+2| ... | ... | @@ -62,6 +62,8 @@ export interface TextGenerationService { |
| 62 | 62 | |
| 63 | 63 | /** |
| 64 | 64 | * TextGenerationShape - Service API for commit/PR text generation. |
| 65 | * | |
| 66 | * Interface isolates provider-specific model behavior behind deterministic method contracts. | |
| 65 | 67 | */ |
| 66 | 68 | export interface TextGenerationShape { |
| 67 | 69 | /** |
apps/server/src/imageMime.ts+26-4| ... | ... | @@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ |
| 29 | 29 | ".webp", |
| 30 | 30 | ]); |
| 31 | 31 | |
| 32 | const 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 | ||
| 42 | const 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 | ||
| 51 | export const isSafeImageExtension = (extension: string): boolean => | |
| 52 | coerceSafeImageExtension(extension) !== null; | |
| 53 | ||
| 32 | 54 | export function parseBase64DataUrl( |
| 33 | 55 | dataUrl: string, |
| 34 | 56 | ): { readonly mimeType: string; readonly base64: string } | null { |
| ... | ... | @@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string |
| 63 | 85 | return fromMime; |
| 64 | 86 | } |
| 65 | 87 | |
| 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) { | |
| 68 | 90 | return fromMimeExtension; |
| 69 | 91 | } |
| 70 | 92 | |
| 71 | 93 | const fileName = input.fileName?.trim() ?? ""; |
| 72 | 94 | 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) { | |
| 75 | 97 | return fileNameExtension; |
| 76 | 98 | } |
| 77 | 99 |
apps/server/src/index.ts+1| ... | ... | @@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" }; |
| 10 | 10 | import { ServerLive } from "./wsServer"; |
| 11 | 11 | import { NetService } from "@t3tools/shared/Net"; |
| 12 | 12 | |
| 13 | // Compose all required services once so every CLI invocation shares the same runtime. | |
| 13 | 14 | const RuntimeLayer = Layer.empty.pipe( |
| 14 | 15 | Layer.provideMerge(CliConfig.layer), |
| 15 | 16 | Layer.provideMerge(ServerLive), |
apps/server/src/main.ts+11-7| ... | ... | @@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net"; |
| 12 | 12 | |
| 13 | 13 | import { |
| 14 | 14 | DEFAULT_PORT, |
| 15 | KEYBINDINGS_CONFIG_FILENAME, | |
| 15 | 16 | resolveStaticDir, |
| 16 | 17 | ServerConfig, |
| 18 | isDesktopMode, | |
| 17 | 19 | type RuntimeMode, |
| 18 | 20 | type ServerConfigShape, |
| 19 | 21 | } from "./config"; |
| ... | ... | @@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) => |
| 133 | 135 | ); |
| 134 | 136 | |
| 135 | 137 | const mode = Option.getOrElse(input.mode, () => env.mode); |
| 138 | const isDesktop = isDesktopMode(mode); | |
| 139 | const isWeb = !isDesktop; | |
| 136 | 140 | |
| 137 | 141 | const port = yield* Option.match(input.port, { |
| 138 | 142 | onSome: (value) => Effect.succeed(value), |
| ... | ... | @@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) => |
| 140 | 144 | if (env.port) { |
| 141 | 145 | return Effect.succeed(env.port); |
| 142 | 146 | } |
| 143 | if (mode === "desktop") { | |
| 147 | if (isDesktop) { | |
| 144 | 148 | return Effect.succeed(DEFAULT_PORT); |
| 145 | 149 | } |
| 146 | 150 | return findAvailablePort(DEFAULT_PORT); |
| ... | ... | @@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) => |
| 153 | 157 | const noBrowser = Option.match(input.noBrowser, { |
| 154 | 158 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 155 | 159 | // 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, | |
| 158 | 162 | }); |
| 159 | 163 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; |
| 160 | 164 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { |
| 161 | 165 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 162 | 166 | // 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, | |
| 165 | 169 | }); |
| 166 | 170 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { |
| 167 | 171 | // effect/cli boolean flags parse to `false` when absent; in that case |
| ... | ... | @@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) => |
| 171 | 175 | }); |
| 172 | 176 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 173 | 177 | const { join } = yield* Path.Path; |
| 174 | const keybindingsConfigPath = join(stateDir, "keybindings.json"); | |
| 178 | const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME); | |
| 175 | 179 | const host = |
| 176 | 180 | Option.getOrUndefined(input.host) ?? |
| 177 | 181 | env.host ?? |
| 178 | (mode === "desktop" ? "127.0.0.1" : undefined); | |
| 182 | (isDesktop ? "127.0.0.1" : undefined); | |
| 179 | 183 | |
| 180 | 184 | return { |
| 181 | 185 | mode, |
apps/server/src/os-jank.ts+1| ... | ... | @@ -3,6 +3,7 @@ import { Effect, Path } from "effect"; |
| 3 | 3 | import { execFileSync } from "node:child_process"; |
| 4 | 4 | |
| 5 | 5 | export function fixPath(): void { |
| 6 | // Only macOS shells need PATH hydration from interactive startup files. | |
| 6 | 7 | if (process.platform !== "darwin") return; |
| 7 | 8 | |
| 8 | 9 | try { |
apps/server/src/wsServer.ts+4-4| ... | ... | @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; |
| 60 | 60 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 61 | 61 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 62 | 62 | import { |
| 63 | ATTACHMENTS_ROUTE_PREFIX, | |
| 64 | 63 | normalizeAttachmentRelativePath, |
| 65 | 64 | resolveAttachmentRelativePath, |
| 65 | stripAttachmentRoutePrefix, | |
| 66 | 66 | } from "./attachmentPaths"; |
| 67 | 67 | import { |
| 68 | 68 | createAttachmentId, |
| ... | ... | @@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 350 | 350 | return; |
| 351 | 351 | } |
| 352 | 352 | |
| 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); | |
| 356 | 356 | if (!normalizedRelativePath) { |
| 357 | 357 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 358 | 358 | return; |
apps/web/src/components/ChatView.tsx+77-22| ... | ... | @@ -340,6 +340,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 340 | 340 | pathValue: string; |
| 341 | 341 | kind: "file" | "directory"; |
| 342 | 342 | theme: "light" | "dark"; |
| 343 | className?: string; | |
| 343 | 344 | }) { |
| 344 | 345 | const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); |
| 345 | 346 | const iconUrl = useMemo( |
| ... | ... | @@ -350,9 +351,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 350 | 351 | |
| 351 | 352 | if (failed) { |
| 352 | 353 | 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)} /> | |
| 354 | 355 | ) : ( |
| 355 | <FileIcon className="size-4 text-muted-foreground/80" /> | |
| 356 | <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | |
| 356 | 357 | ); |
| 357 | 358 | } |
| 358 | 359 | |
| ... | ... | @@ -361,7 +362,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 361 | 362 | src={iconUrl} |
| 362 | 363 | alt="" |
| 363 | 364 | aria-hidden="true" |
| 364 | className="size-4 shrink-0" | |
| 365 | className={cn("size-4 shrink-0", props.className)} | |
| 365 | 366 | loading="lazy" |
| 366 | 367 | onError={() => setFailedIconUrl(iconUrl)} |
| 367 | 368 | /> |
| ... | ... | @@ -2554,6 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2554 | 2555 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 2555 | 2556 | onImageExpand={onExpandTimelineImage} |
| 2556 | 2557 | markdownCwd={gitCwd ?? undefined} |
| 2558 | resolvedTheme={resolvedTheme} | |
| 2557 | 2559 | /> |
| 2558 | 2560 | </div> |
| 2559 | 2561 | |
| ... | ... | @@ -3154,14 +3156,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 3154 | 3156 | ); |
| 3155 | 3157 | }); |
| 3156 | 3158 | |
| 3157 | function buildInitiallyExpandedDirectoryState( | |
| 3158 | nodes: ReadonlyArray<TurnDiffTreeNode>, | |
| 3159 | function 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 | ||
| 3169 | function buildDirectoryExpansionState( | |
| 3170 | directoryPaths: ReadonlyArray<string>, | |
| 3171 | expanded: boolean, | |
| 3159 | 3172 | ): Record<string, boolean> { |
| 3160 | 3173 | 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; | |
| 3165 | 3176 | } |
| 3166 | 3177 | return expandedState; |
| 3167 | 3178 | } |
| ... | ... | @@ -3169,13 +3180,27 @@ function buildInitiallyExpandedDirectoryState( |
| 3169 | 3180 | const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3170 | 3181 | turnId: TurnId; |
| 3171 | 3182 | files: ReadonlyArray<TurnDiffFileChange>; |
| 3183 | allDirectoriesExpanded: boolean; | |
| 3184 | resolvedTheme: "light" | "dark"; | |
| 3172 | 3185 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 3173 | 3186 | }) { |
| 3174 | const { files, onOpenTurnDiff, turnId } = props; | |
| 3187 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | |
| 3175 | 3188 | 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 | ); | |
| 3176 | 3198 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>( |
| 3177 | () => buildInitiallyExpandedDirectoryState(treeNodes), | |
| 3199 | () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), | |
| 3178 | 3200 | ); |
| 3201 | useEffect(() => { | |
| 3202 | setExpandedDirectories(allDirectoryExpansionState); | |
| 3203 | }, [allDirectoryExpansionState]); | |
| 3179 | 3204 | |
| 3180 | 3205 | const toggleDirectory = useCallback((pathValue: string) => { |
| 3181 | 3206 | setExpandedDirectories((current) => ({ |
| ... | ... | @@ -3233,7 +3258,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3233 | 3258 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| 3234 | 3259 | > |
| 3235 | 3260 | <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 | /> | |
| 3237 | 3267 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> |
| 3238 | 3268 | {node.name} |
| 3239 | 3269 | </span> |
| ... | ... | @@ -3272,6 +3302,7 @@ interface MessagesTimelineProps { |
| 3272 | 3302 | isRevertingCheckpoint: boolean; |
| 3273 | 3303 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 3274 | 3304 | markdownCwd: string | undefined; |
| 3305 | resolvedTheme: "light" | "dark"; | |
| 3275 | 3306 | } |
| 3276 | 3307 | |
| 3277 | 3308 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| ... | ... | @@ -3312,6 +3343,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3312 | 3343 | isRevertingCheckpoint, |
| 3313 | 3344 | onImageExpand, |
| 3314 | 3345 | markdownCwd, |
| 3346 | resolvedTheme, | |
| 3315 | 3347 | }: MessagesTimelineProps) { |
| 3316 | 3348 | const timelineRootRef = useRef<HTMLDivElement | null>(null); |
| 3317 | 3349 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); |
| ... | ... | @@ -3484,6 +3516,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3484 | 3516 | |
| 3485 | 3517 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 3486 | 3518 | 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 | }, []); | |
| 3487 | 3528 | |
| 3488 | 3529 | const renderRowContent = (row: TimelineRow) => ( |
| 3489 | 3530 | <div |
| ... | ... | @@ -3659,6 +3700,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3659 | 3700 | if (checkpointFiles.length === 0) return null; |
| 3660 | 3701 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); |
| 3661 | 3702 | const changedFileCountLabel = String(checkpointFiles.length); |
| 3703 | const allDirectoriesExpanded = | |
| 3704 | allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; | |
| 3662 | 3705 | return ( |
| 3663 | 3706 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 3664 | 3707 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| ... | ... | @@ -3674,21 +3717,33 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3674 | 3717 | </> |
| 3675 | 3718 | )} |
| 3676 | 3719 | </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> | |
| 3687 | 3740 | </div> |
| 3688 | 3741 | <ChangedFilesTree |
| 3689 | 3742 | key={`changed-files-tree:${turnSummary.turnId}`} |
| 3690 | 3743 | turnId={turnSummary.turnId} |
| 3691 | 3744 | files={checkpointFiles} |
| 3745 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 3746 | resolvedTheme={resolvedTheme} | |
| 3692 | 3747 | onOpenTurnDiff={onOpenTurnDiff} |
| 3693 | 3748 | /> |
| 3694 | 3749 | </div> |
apps/web/src/lib/turnDiffTree.test.ts+37-15| ... | ... | @@ -96,32 +96,54 @@ describe("buildTurnDiffTree", () => { |
| 96 | 96 | expect(tree).toEqual([ |
| 97 | 97 | { |
| 98 | 98 | kind: "directory", |
| 99 | name: "apps", | |
| 100 | path: "apps", | |
| 99 | name: "apps/web/src", | |
| 100 | path: "apps/web/src", | |
| 101 | 101 | 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 }, | |
| 102 | 126 | children: [ |
| 103 | 127 | { |
| 104 | 128 | kind: "directory", |
| 105 | name: "web", | |
| 106 | path: "apps/web", | |
| 129 | name: "src", | |
| 130 | path: "apps/server/src", | |
| 107 | 131 | stat: { additions: 2, deletions: 1 }, |
| 108 | 132 | children: [ |
| 109 | 133 | { |
| 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", | |
| 113 | 137 | 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 | ], | |
| 122 | 138 | }, |
| 123 | 139 | ], |
| 124 | 140 | }, |
| 141 | { | |
| 142 | kind: "file", | |
| 143 | name: "main.ts", | |
| 144 | path: "apps/server/main.ts", | |
| 145 | stat: { additions: 4, deletions: 0 }, | |
| 146 | }, | |
| 125 | 147 | ], |
| 126 | 148 | }, |
| 127 | 149 | ]); |
apps/web/src/lib/turnDiffTree.ts+27-2| ... | ... | @@ -54,10 +54,34 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null { |
| 54 | 54 | }; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | function 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 | ||
| 57 | 81 | function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { |
| 58 | 82 | const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values()) |
| 59 | 83 | .toSorted(compareByName) |
| 60 | .map((subdirectory) => ({ | |
| 84 | .map<TurnDiffTreeDirectoryNode>((subdirectory) => ({ | |
| 61 | 85 | kind: "directory", |
| 62 | 86 | name: subdirectory.name, |
| 63 | 87 | path: subdirectory.path, |
| ... | ... | @@ -66,7 +90,8 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { |
| 66 | 90 | deletions: subdirectory.stat.deletions, |
| 67 | 91 | }, |
| 68 | 92 | children: toTreeNodes(subdirectory), |
| 69 | })); | |
| 93 | })) | |
| 94 | .map((subdirectory) => compactDirectoryNode(subdirectory)); | |
| 70 | 95 | |
| 71 | 96 | const files = directory.files.toSorted(compareByName); |
| 72 | 97 | return [...subdirectories, ...files]; |