| 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 | 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")) { |
apps/server/src/attachmentStore.ts-1| ... | ... | @@ -14,7 +14,6 @@ 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. | |
| 18 | 17 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 19 | 18 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, |
| 20 | 19 | "i", |
apps/server/src/config.ts+1-4| ... | ... | @@ -9,12 +9,9 @@ |
| 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 |
| 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, |
apps/server/src/git/Errors.ts-2| ... | ... | @@ -59,8 +59,6 @@ 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. | |
| 64 | 62 | */ |
| 65 | 63 | export type GitManagerServiceError = |
| 66 | 64 | | GitManagerError |
apps/server/src/git/Services/GitManager.ts-2| ... | ... | @@ -38,8 +38,6 @@ 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. | |
| 43 | 41 | */ |
| 44 | 42 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( |
| 45 | 43 | "t3/git/Services/GitManager", |
apps/server/src/git/Services/TextGeneration.ts-2| ... | ... | @@ -62,8 +62,6 @@ 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. | |
| 67 | 65 | */ |
| 68 | 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 | 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 |
| 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 |
apps/server/src/index.ts-1| ... | ... | @@ -10,7 +10,6 @@ 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. | |
| 14 | 13 | const RuntimeLayer = Layer.empty.pipe( |
| 15 | 14 | Layer.provideMerge(CliConfig.layer), |
| 16 | 15 | Layer.provideMerge(ServerLive), |
apps/server/src/main.ts+7-11| ... | ... | @@ -12,10 +12,8 @@ import { NetService } from "@t3tools/shared/Net"; |
| 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) => |
| 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) => |
| 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) => |
| 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) => |
| 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, |
apps/server/src/os-jank.ts-1| ... | ... | @@ -3,7 +3,6 @@ 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. | |
| 7 | 6 | if (process.platform !== "darwin") return; |
| 8 | 7 | |
| 9 | 8 | 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, | |
| 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< |
| 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; |
apps/web/src/components/ChatView.tsx+8-6| ... | ... | @@ -3202,10 +3202,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 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: { |
| 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: { |
| 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"> |
apps/web/src/lib/turnDiffTree.test.ts+14| ... | ... | @@ -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 | 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 |