authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 15:49:42-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 15:56:40-08:00
log6841fa17d44e089b4cef03645efd6419a06f7611
tree527dc6f3691dd761641988e011abbddfa51decb1
parent5c03804bac20c305159c2a42c6da7126166e8960

Scope changed-files tree PR to relevant files

- 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";
22
3export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";3export 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
13export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {5export 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;
14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
15const ATTACHMENT_ID_UUID_PATTERN =15const 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.
18const ATTACHMENT_ID_PATTERN = new RegExp(17const 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 @@
9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1010
11export const DEFAULT_PORT = 3773;11export const DEFAULT_PORT = 3773;
12export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const;
1312
14export type RuntimeMode = "web" | "desktop";13export type RuntimeMode = "web" | "desktop";
1514
16export 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>()(
5959
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 */
65export type GitManagerServiceError =63export type GitManagerServiceError =
66 | GitManagerError64 | GitManagerError
apps/server/src/git/Services/GitManager.ts-2
...@@ -38,8 +38,6 @@ export interface GitManagerShape {...@@ -38,8 +38,6 @@ export interface GitManagerShape {
3838
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 */
44export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(42export 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 {
6262
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 */
68export interface TextGenerationShape {66export 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]);
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
54export function parseBase64DataUrl(32export 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 }
8765
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 }
9270
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 }
9977
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" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
11import { NetService } from "@t3tools/shared/Net";11import { NetService } from "@t3tools/shared/Net";
1212
13// Compose all required services once so every CLI invocation shares the same runtime.
14const RuntimeLayer = Layer.empty.pipe(13const 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";
1212
13import {13import {
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 );
136134
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;
140136
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 case154 // 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 case161 // 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 case167 // 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);
183179
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";
3import { execFileSync } from "node:child_process";3import { execFileSync } from "node:child_process";
44
5export function fixPath(): void {5export 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;
87
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";
60import { GitCore } from "./git/Services/GitCore.ts";60import { GitCore } from "./git/Services/GitCore.ts";
61import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";61import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
62import {62import {
63 ATTACHMENTS_ROUTE_PREFIX,
63 normalizeAttachmentRelativePath,64 normalizeAttachmentRelativePath,
64 resolveAttachmentRelativePath,65 resolveAttachmentRelativePath,
65 stripAttachmentRoutePrefix,
66} from "./attachmentPaths";66} from "./attachmentPaths";
67import {67import {
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 }
352352
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]);
32043204
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 }, []);
32113211
...@@ -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 <ChevronRightIcon3224 <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 pathValue36 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}
4241