- 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>
Unified · Split
| @@ -2,14 +2,6 @@ import path from "node:path"; · 1 unmodified line | ||
| 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 | − | |
| 13 | 5 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { |
| 14 | 6 | const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 15 | 7 | if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { |
| @@ -14,7 +14,6 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; · 13 unmodified lines | ||
| 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. | |
| 18 | 17 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 19 | 18 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, |
| 20 | 19 | "i", |
| @@ -9,12 +9,9 @@ · 8 unmodified lines | ||
| 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; | |
| 13 | 12 | |
| 14 | 13 | export type RuntimeMode = "web" | "desktop"; |
| 15 | 14 | |
| 16 | −export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop"; | |
| 17 | − | |
| 18 | 15 | /** |
| 19 | 16 | * ServerConfigShape - Process/runtime configuration required by the server. |
| 20 | 17 | */ |
| @@ -53,7 +50,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS · 32 unmodified lines | ||
| 53 | 50 | port: 0, |
| 54 | 51 | host: undefined, |
| 55 | 52 | authToken: undefined, |
| 56 | − keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME), | |
| 53 | + keybindingsConfigPath: path.join(statedir, "keybindings.json"), | |
| 57 | 54 | staticDir: undefined, |
| 58 | 55 | devUrl: undefined, |
| 59 | 56 | noBrowser: false, |
| @@ -59,8 +59,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( · 58 unmodified lines | ||
| 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. | |
| 64 | 62 | */ |
| 65 | 63 | export type GitManagerServiceError = |
| 66 | 64 | | GitManagerError |
| @@ -38,8 +38,6 @@ export interface GitManagerShape { · 37 unmodified lines | ||
| 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. | |
| 43 | 41 | */ |
| 44 | 42 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( |
| 45 | 43 | "t3/git/Services/GitManager", |
| @@ -62,8 +62,6 @@ export interface TextGenerationService { · 61 unmodified lines | ||
| 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. | |
| 67 | 65 | */ |
| 68 | 66 | export interface TextGenerationShape { |
| 69 | 67 | /** |
| @@ -29,28 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ · 28 unmodified lines | ||
| 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 | − | |
| 54 | 32 | export function parseBase64DataUrl( |
| 55 | 33 | dataUrl: string, |
| 56 | 34 | ): { readonly mimeType: string; readonly base64: string } | null { |
| @@ -85,15 +63,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string · 28 unmodified lines | ||
| 85 | 63 | return fromMime; |
| 86 | 64 | } |
| 87 | 65 | |
| 88 | − const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType)); | |
| 89 | − if (fromMimeExtension) { | |
| 66 | + const fromMimeExtension = Mime.getExtension(input.mimeType); | |
| 67 | + if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { | |
| 90 | 68 | return fromMimeExtension; |
| 91 | 69 | } |
| 92 | 70 | |
| 93 | 71 | const fileName = input.fileName?.trim() ?? ""; |
| 94 | 72 | const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); |
| 95 | − const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null; | |
| 96 | − if (fileNameExtension) { | |
| 73 | + const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; | |
| 74 | + if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { | |
| 97 | 75 | return fileNameExtension; |
| 98 | 76 | } |
| 99 | 77 | |
| @@ -10,7 +10,6 @@ import { version } from "../package.json" with { type: "json" }; · 9 unmodified lines | ||
| 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. | |
| 14 | 13 | const RuntimeLayer = Layer.empty.pipe( |
| 15 | 14 | Layer.provideMerge(CliConfig.layer), |
| 16 | 15 | Layer.provideMerge(ServerLive), |
| @@ -12,10 +12,8 @@ import { NetService } from "@t3tools/shared/Net"; · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import { |
| 14 | 14 | DEFAULT_PORT, |
| 15 | − KEYBINDINGS_CONFIG_FILENAME, | |
| 16 | 15 | resolveStaticDir, |
| 17 | 16 | ServerConfig, |
| 18 | − isDesktopMode, | |
| 19 | 17 | type RuntimeMode, |
| 20 | 18 | type ServerConfigShape, |
| 21 | 19 | } from "./config"; |
| @@ -135,8 +133,6 @@ const ServerConfigLive = (input: CliInput) => · 113 unmodified lines | ||
| 135 | 133 | ); |
| 136 | 134 | |
| 137 | 135 | const mode = Option.getOrElse(input.mode, () => env.mode); |
| 138 | − const isDesktop = isDesktopMode(mode); | |
| 139 | − const isWeb = !isDesktop; | |
| 140 | 136 | |
| 141 | 137 | const port = yield* Option.match(input.port, { |
| 142 | 138 | onSome: (value) => Effect.succeed(value), |
| @@ -144,7 +140,7 @@ const ServerConfigLive = (input: CliInput) => · 1 unmodified line | ||
| 144 | 140 | if (env.port) { |
| 145 | 141 | return Effect.succeed(env.port); |
| 146 | 142 | } |
| 147 | − if (isDesktop) { | |
| 143 | + if (mode === "desktop") { | |
| 148 | 144 | return Effect.succeed(DEFAULT_PORT); |
| 149 | 145 | } |
| 150 | 146 | return findAvailablePort(DEFAULT_PORT); |
| @@ -157,15 +153,15 @@ const ServerConfigLive = (input: CliInput) => · 6 unmodified lines | ||
| 157 | 153 | const noBrowser = Option.match(input.noBrowser, { |
| 158 | 154 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 159 | 155 | // we still want env/mode fallbacks to apply. |
| 160 | − onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)), | |
| 161 | − onNone: () => env.noBrowser ?? isDesktop, | |
| 156 | + onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), | |
| 157 | + onNone: () => env.noBrowser ?? mode === "desktop", | |
| 162 | 158 | }); |
| 163 | 159 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; |
| 164 | 160 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { |
| 165 | 161 | // effect/cli boolean flags parse to `false` when absent; in that case |
| 166 | 162 | // we still want env/mode fallbacks to apply. |
| 167 | − onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)), | |
| 168 | − onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb, | |
| 163 | + onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), | |
| 164 | + onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", | |
| 169 | 165 | }); |
| 170 | 166 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { |
| 171 | 167 | // effect/cli boolean flags parse to `false` when absent; in that case |
| @@ -175,11 +171,11 @@ const ServerConfigLive = (input: CliInput) => · 3 unmodified lines | ||
| 175 | 171 | }); |
| 176 | 172 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 177 | 173 | const { join } = yield* Path.Path; |
| 178 | − const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME); | |
| 174 | + const keybindingsConfigPath = join(stateDir, "keybindings.json"); | |
| 179 | 175 | const host = |
| 180 | 176 | Option.getOrUndefined(input.host) ?? |
| 181 | 177 | env.host ?? |
| 182 | − (isDesktop ? "127.0.0.1" : undefined); | |
| 178 | + (mode === "desktop" ? "127.0.0.1" : undefined); | |
| 183 | 179 | |
| 184 | 180 | return { |
| 185 | 181 | mode, |
| @@ -3,7 +3,6 @@ import { Effect, Path } from "effect"; · 2 unmodified lines | ||
| 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. | |
| 7 | 6 | if (process.platform !== "darwin") return; |
| 8 | 7 | |
| 9 | 8 | try { |
| @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; · 59 unmodified lines | ||
| 60 | 60 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 61 | 61 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 62 | 62 | import { |
| 63 | + ATTACHMENTS_ROUTE_PREFIX, | |
| 63 | 64 | normalizeAttachmentRelativePath, |
| 64 | 65 | 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< · 281 unmodified lines | ||
| 350 | 350 | return; |
| 351 | 351 | } |
| 352 | 352 | |
| 353 | − const routeRelativePath = stripAttachmentRoutePrefix(url.pathname); | |
| 354 | − if (routeRelativePath !== null) { | |
| 355 | − const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath); | |
| 353 | + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { | |
| 354 | + const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | |
| 355 | + const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); | |
| 356 | 356 | if (!normalizedRelativePath) { |
| 357 | 357 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 358 | 358 | return; |
| @@ -3202,10 +3202,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 3201 unmodified lines | ||
| 3202 | 3202 | setExpandedDirectories(allDirectoryExpansionState); |
| 3203 | 3203 | }, [allDirectoryExpansionState]); |
| 3204 | 3204 | |
| 3205 | − const toggleDirectory = useCallback((pathValue: string) => { | |
| 3205 | + const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | |
| 3206 | 3206 | setExpandedDirectories((current) => ({ |
| 3207 | 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: { · 7 unmodified lines | ||
| 3219 | 3219 | type="button" |
| 3220 | 3220 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 3221 | 3221 | style={{ paddingLeft: `${leftPadding}px` }} |
| 3222 | − onClick={() => toggleDirectory(node.path)} | |
| 3222 | + onClick={() => toggleDirectory(node.path, depth === 0)} | |
| 3223 | 3223 | > |
| 3224 | 3224 | <ChevronRightIcon |
| 3225 | 3225 | aria-hidden="true" |
| @@ -3236,9 +3236,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 10 unmodified lines | ||
| 3236 | 3236 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> |
| 3237 | 3237 | {node.name} |
| 3238 | 3238 | </span> |
| 3239 | − <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 3240 | − <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 3241 | − </span> | |
| 3239 | + {hasNonZeroStat(node.stat) && ( | |
| 3240 | + <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 3241 | + <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 3242 | + </span> | |
| 3243 | + )} | |
| 3242 | 3244 | </button> |
| 3243 | 3245 | {isExpanded && ( |
| 3244 | 3246 | <div className="space-y-0.5"> |
| @@ -148,4 +148,18 @@ describe("buildTurnDiffTree", () => { · 147 unmodified lines | ||
| 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 | }); |
| @@ -36,7 +36,6 @@ function normalizePathSegments(pathValue: string): string[] { · 35 unmodified lines | ||
| 36 | 36 | return pathValue |
| 37 | 37 | .replaceAll("\\", "/") |
| 38 | 38 | .split("/") |
| 39 | − .map((segment) => segment.trim()) | |
| 40 | 39 | .filter((segment) => segment.length > 0); |
| 41 | 40 | } |
| 42 | 41 | |