History

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>

Julius Marminge committed 8 months ago

Commit 6841fa11 parent 5c0380414 files · +38 −69Browse files

Unified · Split

14 files
  1. apps/server/src/attachmentPaths.ts +0 −8
  2. apps/server/src/attachmentStore.ts +0 −1
  3. apps/server/src/config.ts +1 −4
  4. apps/server/src/git/Errors.ts +0 −2
  5. apps/server/src/git/Services/GitManager.ts +0 −2
  6. apps/server/src/git/Services/TextGeneration.ts +0 −2
  7. apps/server/src/imageMime.ts +4 −26
  8. apps/server/src/index.ts +0 −1
  9. apps/server/src/main.ts +7 −11
  10. apps/server/src/os-jank.ts +0 −1
  11. apps/server/src/wsServer.ts +4 −4
  12. apps/web/src/components/ChatView.tsx +8 −6
  13. apps/web/src/lib/turnDiffTree.test.ts +14 −0
  14. apps/web/src/lib/turnDiffTree.ts +0 −1
apps/server/src/attachmentPaths.ts+0−8
@@ -2,14 +2,6 @@ import path from "node:path"; · 1 unmodified line
22
33 export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
44
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−
135 export 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+0−1
@@ -14,7 +14,6 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; · 13 unmodified lines
1414 const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
1515 const 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.
1817 const 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 @@ · 8 unmodified lines
99 import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1010
1111 export const DEFAULT_PORT = 3773;
12−export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const;
1312
1413 export type RuntimeMode = "web" | "desktop";
1514
16−export 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 · 32 unmodified lines
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+0−2
@@ -59,8 +59,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( · 58 unmodified lines
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 */
6563 export type GitManagerServiceError =
6664 | GitManagerError
apps/server/src/git/Services/GitManager.ts+0−2
@@ -38,8 +38,6 @@ export interface GitManagerShape { · 37 unmodified lines
3838
3939 /**
4040 * GitManager - Service tag for stacked Git workflow orchestration.
41− *
42− * Single tag keeps orchestration wiring swappable in Layer-based tests.
4341 */
4442 export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(
4543 "t3/git/Services/GitManager",
apps/server/src/git/Services/TextGeneration.ts+0−2
@@ -62,8 +62,6 @@ export interface TextGenerationService { · 61 unmodified lines
6262
6363 /**
6464 * TextGenerationShape - Service API for commit/PR text generation.
65− *
66− * Interface isolates provider-specific model behavior behind deterministic method contracts.
6765 */
6866 export interface TextGenerationShape {
6967 /**
apps/server/src/imageMime.ts+4−26
@@ -29,28 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ · 28 unmodified lines
2929 ".webp",
3030 ]);
3131
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−
5432 export 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 · 28 unmodified lines
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+0−1
@@ -10,7 +10,6 @@ import { version } from "../package.json" with { type: "json" }; · 9 unmodified lines
1010 import { ServerLive } from "./wsServer";
1111 import { NetService } from "@t3tools/shared/Net";
1212
13−// Compose all required services once so every CLI invocation shares the same runtime.
1413 const 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"; · 11 unmodified lines
1212
1313 import {
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) => · 113 unmodified lines
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) => · 1 unmodified line
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) => · 6 unmodified lines
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) => · 3 unmodified lines
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+0−1
@@ -3,7 +3,6 @@ import { Effect, Path } from "effect"; · 2 unmodified lines
33 import { execFileSync } from "node:child_process";
44
55 export 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"; · 59 unmodified lines
6060 import { GitCore } from "./git/Services/GitCore.ts";
6161 import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
6262 import {
63+ ATTACHMENTS_ROUTE_PREFIX,
6364 normalizeAttachmentRelativePath,
6465 resolveAttachmentRelativePath,
65− stripAttachmentRoutePrefix,
6666 } from "./attachmentPaths";
6767 import {
6868 createAttachmentId,
@@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 281 unmodified lines
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: { · 3201 unmodified lines
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: { · 7 unmodified lines
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: { · 10 unmodified lines
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−0
@@ -148,4 +148,18 @@ describe("buildTurnDiffTree", () => { · 147 unmodified lines
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+0−1
@@ -36,7 +36,6 @@ function normalizePathSegments(pathValue: string): string[] { · 35 unmodified lines
3636 return pathValue
3737 .replaceAll("\\", "/")
3838 .split("/")
39− .map((segment) => segment.trim())
4039 .filter((segment) => segment.length > 0);
4140 }
4241