| author | |
| committer | |
| log | 6841fa17d44e089b4cef03645efd6419a06f7611 |
| tree | 527dc6f3691dd761641988e011abbddfa51decb1 |
| parent | 5c03804bac20c305159c2a42c6da7126166e8960 |
- revert unrelated server-side attachment/image changes from this branch\n- keep changed-files tree UX fixes (icons, directory behavior, whitespace-safe paths, stat visibility)\n\nCo-authored-by: codex <codex@users.noreply.github.com>14 files changed, 38 insertions(+), 69 deletions(-)
apps/server/src/attachmentPaths.ts-8| ... | @@ -2,14 +2,6 @@ import path from "node:path"; | ... | @@ -2,14 +2,6 @@ import path from "node:path"; |
| 2 | 2 | ||
| 3 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; | 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 | |||
| 13 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { | 5 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { |
| 14 | const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | 6 | const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 15 | if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { | 7 | if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { |
apps/server/src/attachmentStore.ts-1| ... | @@ -14,7 +14,6 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; | ... | @@ -14,7 +14,6 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; |
| 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; | 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 15 | const ATTACHMENT_ID_UUID_PATTERN = | 15 | const ATTACHMENT_ID_UUID_PATTERN = |
| 16 | "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; | 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. | ||
| 18 | const ATTACHMENT_ID_PATTERN = new RegExp( | 17 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 19 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, | 18 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, |
| 20 | "i", | 19 | "i", |
apps/server/src/config.ts+1-4| ... | @@ -9,12 +9,9 @@ | ... | @@ -9,12 +9,9 @@ |
| 9 | import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | 9 | import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; |
| 10 | 10 | ||
| 11 | export const DEFAULT_PORT = 3773; | 11 | export const DEFAULT_PORT = 3773; |
| 12 | export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const; | ||
| 13 | 12 | ||
| 14 | export type RuntimeMode = "web" | "desktop"; | 13 | export type RuntimeMode = "web" | "desktop"; |
| 15 | 14 | ||
| 16 | export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop"; | ||
| 17 | |||
| 18 | /** | 15 | /** |
| 19 | * ServerConfigShape - Process/runtime configuration required by the server. | 16 | * ServerConfigShape - Process/runtime configuration required by the server. |
| 20 | */ | 17 | */ |
| ... | @@ -53,7 +50,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS | ... | @@ -53,7 +50,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 53 | port: 0, | 50 | port: 0, |
| 54 | host: undefined, | 51 | host: undefined, |
| 55 | authToken: undefined, | 52 | authToken: undefined, |
| 56 | keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME), | 53 | keybindingsConfigPath: path.join(statedir, "keybindings.json"), |
| 57 | staticDir: undefined, | 54 | staticDir: undefined, |
| 58 | devUrl: undefined, | 55 | devUrl: undefined, |
| 59 | noBrowser: false, | 56 | noBrowser: false, |
apps/server/src/git/Errors.ts-2| ... | @@ -59,8 +59,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( | ... | @@ -59,8 +59,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( |
| 59 | 59 | ||
| 60 | /** | 60 | /** |
| 61 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. | 61 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. |
| 62 | * | ||
| 63 | * Keep this union centralized so callers can pattern-match once at API boundaries. | ||
| 64 | */ | 62 | */ |
| 65 | export type GitManagerServiceError = | 63 | export type GitManagerServiceError = |
| 66 | | GitManagerError | 64 | | GitManagerError |
apps/server/src/git/Services/GitManager.ts-2| ... | @@ -38,8 +38,6 @@ export interface GitManagerShape { | ... | @@ -38,8 +38,6 @@ export interface GitManagerShape { |
| 38 | 38 | ||
| 39 | /** | 39 | /** |
| 40 | * GitManager - Service tag for stacked Git workflow orchestration. | 40 | * GitManager - Service tag for stacked Git workflow orchestration. |
| 41 | * | ||
| 42 | * Single tag keeps orchestration wiring swappable in Layer-based tests. | ||
| 43 | */ | 41 | */ |
| 44 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( | 42 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( |
| 45 | "t3/git/Services/GitManager", | 43 | "t3/git/Services/GitManager", |
apps/server/src/git/Services/TextGeneration.ts-2| ... | @@ -62,8 +62,6 @@ export interface TextGenerationService { | ... | @@ -62,8 +62,6 @@ export interface TextGenerationService { |
| 62 | 62 | ||
| 63 | /** | 63 | /** |
| 64 | * TextGenerationShape - Service API for commit/PR text generation. | 64 | * TextGenerationShape - Service API for commit/PR text generation. |
| 65 | * | ||
| 66 | * Interface isolates provider-specific model behavior behind deterministic method contracts. | ||
| 67 | */ | 65 | */ |
| 68 | export interface TextGenerationShape { | 66 | export interface TextGenerationShape { |
| 69 | /** | 67 | /** |
apps/server/src/imageMime.ts+4-26| ... | @@ -29,28 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ | ... | @@ -29,28 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ |
| 29 | ".webp", | 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 | |||
| 54 | export function parseBase64DataUrl( | 32 | export function parseBase64DataUrl( |
| 55 | dataUrl: string, | 33 | dataUrl: string, |
| 56 | ): { readonly mimeType: string; readonly base64: string } | null { | 34 | ): { readonly mimeType: string; readonly base64: string } | null { |
| ... | @@ -85,15 +63,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string | ... | @@ -85,15 +63,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string |
| 85 | return fromMime; | 63 | return fromMime; |
| 86 | } | 64 | } |
| 87 | 65 | ||
| 88 | const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType)); | 66 | const fromMimeExtension = Mime.getExtension(input.mimeType); |
| 89 | if (fromMimeExtension) { | 67 | if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { |
| 90 | return fromMimeExtension; | 68 | return fromMimeExtension; |
| 91 | } | 69 | } |
| 92 | 70 | ||
| 93 | const fileName = input.fileName?.trim() ?? ""; | 71 | const fileName = input.fileName?.trim() ?? ""; |
| 94 | const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); | 72 | const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); |
| 95 | const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null; | 73 | const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; |
| 96 | if (fileNameExtension) { | 74 | if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { |
| 97 | return fileNameExtension; | 75 | return fileNameExtension; |
| 98 | } | 76 | } |
| 99 | 77 |
apps/server/src/index.ts-1| ... | @@ -10,7 +10,6 @@ import { version } from "../package.json" with { type: "json" }; | ... | @@ -10,7 +10,6 @@ import { version } from "../package.json" with { type: "json" }; |
| 10 | import { ServerLive } from "./wsServer"; | 10 | import { ServerLive } from "./wsServer"; |
| 11 | import { NetService } from "@t3tools/shared/Net"; | 11 | import { NetService } from "@t3tools/shared/Net"; |
| 12 | 12 | ||
| 13 | // Compose all required services once so every CLI invocation shares the same runtime. | ||
| 14 | const RuntimeLayer = Layer.empty.pipe( | 13 | const RuntimeLayer = Layer.empty.pipe( |
| 15 | Layer.provideMerge(CliConfig.layer), | 14 | Layer.provideMerge(CliConfig.layer), |
| 16 | Layer.provideMerge(ServerLive), | 15 | Layer.provideMerge(ServerLive), |
apps/server/src/main.ts+7-11| ... | @@ -12,10 +12,8 @@ import { NetService } from "@t3tools/shared/Net"; | ... | @@ -12,10 +12,8 @@ import { NetService } from "@t3tools/shared/Net"; |
| 12 | 12 | ||
| 13 | import { | 13 | import { |
| 14 | DEFAULT_PORT, | 14 | DEFAULT_PORT, |
| 15 | KEYBINDINGS_CONFIG_FILENAME, | ||
| 16 | resolveStaticDir, | 15 | resolveStaticDir, |
| 17 | ServerConfig, | 16 | ServerConfig, |
| 18 | isDesktopMode, | ||
| 19 | type RuntimeMode, | 17 | type RuntimeMode, |
| 20 | type ServerConfigShape, | 18 | type ServerConfigShape, |
| 21 | } from "./config"; | 19 | } from "./config"; |
| ... | @@ -135,8 +133,6 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -135,8 +133,6 @@ const ServerConfigLive = (input: CliInput) => |
| 135 | ); | 133 | ); |
| 136 | 134 | ||
| 137 | const mode = Option.getOrElse(input.mode, () => env.mode); | 135 | const mode = Option.getOrElse(input.mode, () => env.mode); |
| 138 | const isDesktop = isDesktopMode(mode); | ||
| 139 | const isWeb = !isDesktop; | ||
| 140 | 136 | ||
| 141 | const port = yield* Option.match(input.port, { | 137 | const port = yield* Option.match(input.port, { |
| 142 | onSome: (value) => Effect.succeed(value), | 138 | onSome: (value) => Effect.succeed(value), |
| ... | @@ -144,7 +140,7 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -144,7 +140,7 @@ const ServerConfigLive = (input: CliInput) => |
| 144 | if (env.port) { | 140 | if (env.port) { |
| 145 | return Effect.succeed(env.port); | 141 | return Effect.succeed(env.port); |
| 146 | } | 142 | } |
| 147 | if (isDesktop) { | 143 | if (mode === "desktop") { |
| 148 | return Effect.succeed(DEFAULT_PORT); | 144 | return Effect.succeed(DEFAULT_PORT); |
| 149 | } | 145 | } |
| 150 | return findAvailablePort(DEFAULT_PORT); | 146 | return findAvailablePort(DEFAULT_PORT); |
| ... | @@ -157,15 +153,15 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -157,15 +153,15 @@ const ServerConfigLive = (input: CliInput) => |
| 157 | const noBrowser = Option.match(input.noBrowser, { | 153 | const noBrowser = Option.match(input.noBrowser, { |
| 158 | // effect/cli boolean flags parse to `false` when absent; in that case | 154 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 159 | // we still want env/mode fallbacks to apply. | 155 | // we still want env/mode fallbacks to apply. |
| 160 | onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)), | 156 | onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), |
| 161 | onNone: () => env.noBrowser ?? isDesktop, | 157 | onNone: () => env.noBrowser ?? mode === "desktop", |
| 162 | }); | 158 | }); |
| 163 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | 159 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; |
| 164 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { | 160 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { |
| 165 | // effect/cli boolean flags parse to `false` when absent; in that case | 161 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 166 | // we still want env/mode fallbacks to apply. | 162 | // we still want env/mode fallbacks to apply. |
| 167 | onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)), | 163 | onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), |
| 168 | onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb, | 164 | onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", |
| 169 | }); | 165 | }); |
| 170 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { | 166 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { |
| 171 | // effect/cli boolean flags parse to `false` when absent; in that case | 167 | // effect/cli boolean flags parse to `false` when absent; in that case |
| ... | @@ -175,11 +171,11 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -175,11 +171,11 @@ const ServerConfigLive = (input: CliInput) => |
| 175 | }); | 171 | }); |
| 176 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; | 172 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 177 | const { join } = yield* Path.Path; | 173 | const { join } = yield* Path.Path; |
| 178 | const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME); | 174 | const keybindingsConfigPath = join(stateDir, "keybindings.json"); |
| 179 | const host = | 175 | const host = |
| 180 | Option.getOrUndefined(input.host) ?? | 176 | Option.getOrUndefined(input.host) ?? |
| 181 | env.host ?? | 177 | env.host ?? |
| 182 | (isDesktop ? "127.0.0.1" : undefined); | 178 | (mode === "desktop" ? "127.0.0.1" : undefined); |
| 183 | 179 | ||
| 184 | return { | 180 | return { |
| 185 | mode, | 181 | mode, |
apps/server/src/os-jank.ts-1| ... | @@ -3,7 +3,6 @@ import { Effect, Path } from "effect"; | ... | @@ -3,7 +3,6 @@ import { Effect, Path } from "effect"; |
| 3 | import { execFileSync } from "node:child_process"; | 3 | import { execFileSync } from "node:child_process"; |
| 4 | 4 | ||
| 5 | export function fixPath(): void { | 5 | export function fixPath(): void { |
| 6 | // Only macOS shells need PATH hydration from interactive startup files. | ||
| 7 | if (process.platform !== "darwin") return; | 6 | if (process.platform !== "darwin") return; |
| 8 | 7 | ||
| 9 | try { | 8 | try { |
apps/server/src/wsServer.ts+4-4| ... | @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; | ... | @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; |
| 60 | import { GitCore } from "./git/Services/GitCore.ts"; | 60 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 61 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 61 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 62 | import { | 62 | import { |
| 63 | ATTACHMENTS_ROUTE_PREFIX, | ||
| 63 | normalizeAttachmentRelativePath, | 64 | normalizeAttachmentRelativePath, |
| 64 | resolveAttachmentRelativePath, | 65 | resolveAttachmentRelativePath, |
| 65 | stripAttachmentRoutePrefix, | ||
| 66 | } from "./attachmentPaths"; | 66 | } from "./attachmentPaths"; |
| 67 | import { | 67 | import { |
| 68 | createAttachmentId, | 68 | createAttachmentId, |
| ... | @@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 350 | return; | 350 | return; |
| 351 | } | 351 | } |
| 352 | 352 | ||
| 353 | const routeRelativePath = stripAttachmentRoutePrefix(url.pathname); | 353 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { |
| 354 | if (routeRelativePath !== null) { | 354 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); |
| 355 | const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath); | 355 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); |
| 356 | if (!normalizedRelativePath) { | 356 | if (!normalizedRelativePath) { |
| 357 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | 357 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 358 | return; | 358 | return; |
apps/web/src/components/ChatView.tsx+8-6| ... | @@ -3202,10 +3202,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -3202,10 +3202,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3202 | setExpandedDirectories(allDirectoryExpansionState); | 3202 | setExpandedDirectories(allDirectoryExpansionState); |
| 3203 | }, [allDirectoryExpansionState]); | 3203 | }, [allDirectoryExpansionState]); |
| 3204 | 3204 | ||
| 3205 | const toggleDirectory = useCallback((pathValue: string) => { | 3205 | const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { |
| 3206 | setExpandedDirectories((current) => ({ | 3206 | setExpandedDirectories((current) => ({ |
| 3207 | ...current, | 3207 | ...current, |
| 3208 | [pathValue]: !current[pathValue], | 3208 | [pathValue]: !(current[pathValue] ?? fallbackExpanded), |
| 3209 | })); | 3209 | })); |
| 3210 | }, []); | 3210 | }, []); |
| 3211 | 3211 | ||
| ... | @@ -3219,7 +3219,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -3219,7 +3219,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3219 | type="button" | 3219 | type="button" |
| 3220 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | 3220 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 3221 | style={{ paddingLeft: `${leftPadding}px` }} | 3221 | style={{ paddingLeft: `${leftPadding}px` }} |
| 3222 | onClick={() => toggleDirectory(node.path)} | 3222 | onClick={() => toggleDirectory(node.path, depth === 0)} |
| 3223 | > | 3223 | > |
| 3224 | <ChevronRightIcon | 3224 | <ChevronRightIcon |
| 3225 | aria-hidden="true" | 3225 | aria-hidden="true" |
| ... | @@ -3236,9 +3236,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -3236,9 +3236,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3236 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | 3236 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> |
| 3237 | {node.name} | 3237 | {node.name} |
| 3238 | </span> | 3238 | </span> |
| 3239 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | 3239 | {hasNonZeroStat(node.stat) && ( |
| 3240 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | 3240 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 3241 | </span> | 3241 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 3242 | </span> | ||
| 3243 | )} | ||
| 3242 | </button> | 3244 | </button> |
| 3243 | {isExpanded && ( | 3245 | {isExpanded && ( |
| 3244 | <div className="space-y-0.5"> | 3246 | <div className="space-y-0.5"> |
apps/web/src/lib/turnDiffTree.test.ts+14| ... | @@ -148,4 +148,18 @@ describe("buildTurnDiffTree", () => { | ... | @@ -148,4 +148,18 @@ describe("buildTurnDiffTree", () => { |
| 148 | }, | 148 | }, |
| 149 | ]); | 149 | ]); |
| 150 | }); | 150 | }); |
| 151 | |||
| 152 | it("preserves leading/trailing whitespace in path segments", () => { | ||
| 153 | const tree = buildTurnDiffTree([ | ||
| 154 | { path: "a/file.ts", additions: 1, deletions: 0 }, | ||
| 155 | { path: " a/file.ts", additions: 2, deletions: 0 }, | ||
| 156 | ]); | ||
| 157 | |||
| 158 | expect(tree).toHaveLength(2); | ||
| 159 | const directoryNodes = tree.filter( | ||
| 160 | (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => node.kind === "directory", | ||
| 161 | ); | ||
| 162 | expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]); | ||
| 163 | expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); | ||
| 164 | }); | ||
| 151 | }); | 165 | }); |
apps/web/src/lib/turnDiffTree.ts-1| ... | @@ -36,7 +36,6 @@ function normalizePathSegments(pathValue: string): string[] { | ... | @@ -36,7 +36,6 @@ function normalizePathSegments(pathValue: string): string[] { |
| 36 | return pathValue | 36 | return pathValue |
| 37 | .replaceAll("\\", "/") | 37 | .replaceAll("\\", "/") |
| 38 | .split("/") | 38 | .split("/") |
| 39 | .map((segment) => segment.trim()) | ||
| 40 | .filter((segment) => segment.length > 0); | 39 | .filter((segment) => segment.length > 0); |
| 41 | } | 40 | } |
| 42 | 41 |