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";
22
33export 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
135export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
146 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
157 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;
1414const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
1515const ATTACHMENT_ID_UUID_PATTERN =
1616 "[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.
1817const ATTACHMENT_ID_PATTERN = new RegExp(
1918 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
2019 "i",
apps/server/src/config.ts+1-4
......@@ -9,12 +9,9 @@
99import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1010
1111export const DEFAULT_PORT = 3773;
12export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const;
1312
1413export type RuntimeMode = "web" | "desktop";
1514
16export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop";
17
1815/**
1916 * ServerConfigShape - Process/runtime configuration required by the server.
2017 */
......@@ -53,7 +50,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
5350 port: 0,
5451 host: undefined,
5552 authToken: undefined,
56 keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME),
53 keybindingsConfigPath: path.join(statedir, "keybindings.json"),
5754 staticDir: undefined,
5855 devUrl: undefined,
5956 noBrowser: false,
apps/server/src/git/Errors.ts-2
......@@ -59,8 +59,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(
5959
6060/**
6161 * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
62 *
63 * Keep this union centralized so callers can pattern-match once at API boundaries.
6462 */
6563export type GitManagerServiceError =
6664 | GitManagerError
apps/server/src/git/Services/GitManager.ts-2
......@@ -38,8 +38,6 @@ export interface GitManagerShape {
3838
3939/**
4040 * GitManager - Service tag for stacked Git workflow orchestration.
41 *
42 * Single tag keeps orchestration wiring swappable in Layer-based tests.
4341 */
4442export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(
4543 "t3/git/Services/GitManager",
apps/server/src/git/Services/TextGeneration.ts-2
......@@ -62,8 +62,6 @@ export interface TextGenerationService {
6262
6363/**
6464 * TextGenerationShape - Service API for commit/PR text generation.
65 *
66 * Interface isolates provider-specific model behavior behind deterministic method contracts.
6765 */
6866export interface TextGenerationShape {
6967 /**
apps/server/src/imageMime.ts+4-26
......@@ -29,28 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
2929 ".webp",
3030]);
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
5432export function parseBase64DataUrl(
5533 dataUrl: string,
5634): { readonly mimeType: string; readonly base64: string } | null {
......@@ -85,15 +63,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string
8563 return fromMime;
8664 }
8765
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)) {
9068 return fromMimeExtension;
9169 }
9270
9371 const fileName = input.fileName?.trim() ?? "";
9472 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)) {
9775 return fileNameExtension;
9876 }
9977
apps/server/src/index.ts-1
......@@ -10,7 +10,6 @@ import { version } from "../package.json" with { type: "json" };
1010import { ServerLive } from "./wsServer";
1111import { NetService } from "@t3tools/shared/Net";
1212
13// Compose all required services once so every CLI invocation shares the same runtime.
1413const RuntimeLayer = Layer.empty.pipe(
1514 Layer.provideMerge(CliConfig.layer),
1615 Layer.provideMerge(ServerLive),
apps/server/src/main.ts+7-11
......@@ -12,10 +12,8 @@ import { NetService } from "@t3tools/shared/Net";
1212
1313import {
1414 DEFAULT_PORT,
15 KEYBINDINGS_CONFIG_FILENAME,
1615 resolveStaticDir,
1716 ServerConfig,
18 isDesktopMode,
1917 type RuntimeMode,
2018 type ServerConfigShape,
2119} from "./config";
......@@ -135,8 +133,6 @@ const ServerConfigLive = (input: CliInput) =>
135133 );
136134
137135 const mode = Option.getOrElse(input.mode, () => env.mode);
138 const isDesktop = isDesktopMode(mode);
139 const isWeb = !isDesktop;
140136
141137 const port = yield* Option.match(input.port, {
142138 onSome: (value) => Effect.succeed(value),
......@@ -144,7 +140,7 @@ const ServerConfigLive = (input: CliInput) =>
144140 if (env.port) {
145141 return Effect.succeed(env.port);
146142 }
147 if (isDesktop) {
143 if (mode === "desktop") {
148144 return Effect.succeed(DEFAULT_PORT);
149145 }
150146 return findAvailablePort(DEFAULT_PORT);
......@@ -157,15 +153,15 @@ const ServerConfigLive = (input: CliInput) =>
157153 const noBrowser = Option.match(input.noBrowser, {
158154 // effect/cli boolean flags parse to `false` when absent; in that case
159155 // 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",
162158 });
163159 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
164160 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {
165161 // effect/cli boolean flags parse to `false` when absent; in that case
166162 // 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",
169165 });
170166 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {
171167 // effect/cli boolean flags parse to `false` when absent; in that case
......@@ -175,11 +171,11 @@ const ServerConfigLive = (input: CliInput) =>
175171 });
176172 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
177173 const { join } = yield* Path.Path;
178 const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME);
174 const keybindingsConfigPath = join(stateDir, "keybindings.json");
179175 const host =
180176 Option.getOrUndefined(input.host) ??
181177 env.host ??
182 (isDesktop ? "127.0.0.1" : undefined);
178 (mode === "desktop" ? "127.0.0.1" : undefined);
183179
184180 return {
185181 mode,
apps/server/src/os-jank.ts-1
......@@ -3,7 +3,6 @@ import { Effect, Path } from "effect";
33import { execFileSync } from "node:child_process";
44
55export function fixPath(): void {
6 // Only macOS shells need PATH hydration from interactive startup files.
76 if (process.platform !== "darwin") return;
87
98 try {
apps/server/src/wsServer.ts+4-4
......@@ -60,9 +60,9 @@ import { ServerConfig } from "./config";
6060import { GitCore } from "./git/Services/GitCore.ts";
6161import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
6262import {
63 ATTACHMENTS_ROUTE_PREFIX,
6364 normalizeAttachmentRelativePath,
6465 resolveAttachmentRelativePath,
65 stripAttachmentRoutePrefix,
6666} from "./attachmentPaths";
6767import {
6868 createAttachmentId,
......@@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
350350 return;
351351 }
352352
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);
356356 if (!normalizedRelativePath) {
357357 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
358358 return;
apps/web/src/components/ChatView.tsx+8-6
......@@ -3202,10 +3202,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
32023202 setExpandedDirectories(allDirectoryExpansionState);
32033203 }, [allDirectoryExpansionState]);
32043204
3205 const toggleDirectory = useCallback((pathValue: string) => {
3205 const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => {
32063206 setExpandedDirectories((current) => ({
32073207 ...current,
3208 [pathValue]: !current[pathValue],
3208 [pathValue]: !(current[pathValue] ?? fallbackExpanded),
32093209 }));
32103210 }, []);
32113211
......@@ -3219,7 +3219,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
32193219 type="button"
32203220 className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
32213221 style={{ paddingLeft: `${leftPadding}px` }}
3222 onClick={() => toggleDirectory(node.path)}
3222 onClick={() => toggleDirectory(node.path, depth === 0)}
32233223 >
32243224 <ChevronRightIcon
32253225 aria-hidden="true"
......@@ -3236,9 +3236,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
32363236 <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
32373237 {node.name}
32383238 </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 )}
32423244 </button>
32433245 {isExpanded && (
32443246 <div className="space-y-0.5">
apps/web/src/lib/turnDiffTree.test.ts+14
......@@ -148,4 +148,18 @@ describe("buildTurnDiffTree", () => {
148148 },
149149 ]);
150150 });
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 });
151165});
apps/web/src/lib/turnDiffTree.ts-1
......@@ -36,7 +36,6 @@ function normalizePathSegments(pathValue: string): string[] {
3636 return pathValue
3737 .replaceAll("\\", "/")
3838 .split("/")
39 .map((segment) => segment.trim())
4039 .filter((segment) => segment.length > 0);
4140}
4241