Before submitting
Area
apps/server
Steps to reproduce
A workspace that contains two distinct files whose names differ only by trailing whitespace kills
the entire Files panel. Both names are legal on macOS and Linux, and they show up in real repos
after a bad touch "$(pbpaste)" or a copy/paste into a shell.
python3 - <<'EOF'
import os, subprocess
R = "/tmp/dup-path-repro"
os.makedirs(os.path.join(R, "pkg"))
subprocess.run(["git", "init", "-q"], cwd=R, check=True)
open(os.path.join(R, "pkg", "notes.txt"), "w").write("normal file\n")
# Same name plus a trailing newline: a second, real file. Note this cannot be
# created with `touch $'pkg/notes.txt\n'` — the shell strips trailing newlines.
open(os.path.join(R, "pkg", "notes.txt\n"), "w").write("evil twin\n")
EOF
- Open
/tmp/dup-path-repro in T3 Code.
- Open the Files panel.
The workspace only needs one such file anywhere in the tree for the whole panel to break.
Expected behavior
The file tree renders. Either both files appear as separate rows, or the whitespace-suffixed one is
dropped — but the panel stays usable.
Actual behavior
The Files panel renders nothing and throws:
Error: Duplicate path: "pkg/notes.txt"
at yn.appendPreparedPath (t3code://app/assets/FilePreviewPanel-<hash>.js:1:15031)
at yn.appendPreparedPaths (t3code://app/assets/FilePreviewPanel-<hash>.js:1:10832)
at Fa.resetPaths (t3code://app/assets/FilePreviewPanel-<hash>.js:1671:17085)
at Ml.resetPaths (t3code://app/assets/FilePreviewPanel-<hash>.js:1671:97106)
There is no recovery from inside the app — refresh, reopen, and restart all hit the same throw. The
only fix is deleting the offending file from a terminal.
Root cause
The server dedupes entries before the normalization that creates the collision.
apps/server/src/workspace/WorkspaceSearchIndex.ts:281 — withDirectoryAncestors dedupes with
new Map(entries.map((entry) => [entry.path, entry])). The index reports both files, and
"pkg/notes.txt" !== "pkg/notes.txt\n", so both survive.
apps/server/src/workspace/WorkspaceSearchIndex.ts:435 — list() sorts with localeCompare,
which treats \n as ignorable. The pair compares equal and lands adjacent.
packages/contracts/src/project.ts:28 — ProjectEntry.path is TrimmedNonEmptyString, and
packages/contracts/src/baseSchemas.ts:6 runs value.trim() on both decode and encode. The
trailing newline is stripped here, so the client receives two byte-identical, adjacent paths.
apps/web/src/components/files/FileBrowserPanel.tsx:118 maps them into treePaths and :262
calls model.resetPaths(treePaths). @pierre/trees' path-store builder throws on its
adjacent-duplicate guard (dist/path-store/src/builder.js:383).
Confirmed against the library in isolation with exactly the paths the client receives:
const paths = ["pkg/", "pkg/notes.txt", "pkg/notes.txt"];
builder.appendPreparedPaths(paths.map(parseInputPath));
// THREW: Duplicate path: "pkg/notes.txt"
Failing test
Drop this in apps/server/src/workspace/ and run
bun run test src/workspace/WorkspaceSearchIndex.duplicatePathRepro.test.ts from apps/server:
import { expect, it } from "@effect/vitest";
import { ProjectEntry } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
/**
* A workspace can legitimately contain two distinct files whose names differ
* only by surrounding whitespace ("notes.txt" and "notes.txt\n"). The index
* dedupes on the raw name, but ProjectEntry.path is a TrimmedNonEmptyString,
* so both entries collapse to the same string once they cross the wire — and
* the file tree throws `Duplicate path: "..."` on the adjacent pair.
*/
function makeWorkspace(): string {
const root = mkdtempSync(join(tmpdir(), "t3-dup-path-"));
execFileSync("git", ["init", "-q"], { cwd: root });
mkdirSync(join(root, "pkg"));
writeFileSync(join(root, "pkg", "notes.txt"), "");
writeFileSync(join(root, "pkg", "notes.txt\n"), "");
return root;
}
it.effect("does not emit two entries that collapse to the same trimmed path", () =>
Effect.scoped(
Effect.gen(function* () {
const root = makeWorkspace();
const index = yield* WorkspaceSearchIndex.make(root);
const { entries } = yield* index.list();
const rawPaths = entries.map((entry) => entry.path).filter((p) => p.includes("notes.txt"));
// Both files are indexed as distinct strings, so the server-side dedupe
// (a Map keyed on the raw path) keeps both.
expect(rawPaths).toHaveLength(2);
// ProjectEntry trims on encode/decode, which is what the client receives.
const encode = Schema.encodeUnknownSync(Schema.Array(ProjectEntry));
const wirePaths = encode(entries).map((entry) => entry.path);
const duplicates = wirePaths.filter((path, i) => wirePaths.indexOf(path) !== i);
expect(duplicates).toEqual([]);
}),
),
);
Current output:
AssertionError: expected [ 'pkg/notes.txt' ] to deeply equal []
Suggested fix
Normalize in toProjectEntry (WorkspaceSearchIndex.ts:138) so withDirectoryAncestors dedupes on
the same string the client will see. That is the actual bug and it is a one-line change.
Optionally also drop adjacent duplicates when building treePaths
(FileBrowserPanel.tsx:118), so a single odd filename can never take down the whole panel again.
Impact
Major degradation or frequent failure — the Files panel is completely unusable for the affected
workspace until the file is deleted outside the app.
Version or commit
main @ a483337a0
Environment
macOS 15 (Darwin 25.5.0), Apple Silicon, desktop app.
Workaround
Delete the whitespace-suffixed file:
python3 -c "import os; os.remove('pkg/notes.txt\n')"
To find offenders in a workspace:
python3 - <<'EOF'
import os
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in ('.git', 'node_modules', '.venv', '__pycache__')]
for n in files + dirs:
if n != n.strip():
print(repr(os.path.join(root, n)))
EOF
Before submitting
Area
apps/serverSteps to reproduce
A workspace that contains two distinct files whose names differ only by trailing whitespace kills
the entire Files panel. Both names are legal on macOS and Linux, and they show up in real repos
after a bad
touch "$(pbpaste)"or a copy/paste into a shell./tmp/dup-path-reproin T3 Code.The workspace only needs one such file anywhere in the tree for the whole panel to break.
Expected behavior
The file tree renders. Either both files appear as separate rows, or the whitespace-suffixed one is
dropped — but the panel stays usable.
Actual behavior
The Files panel renders nothing and throws:
There is no recovery from inside the app — refresh, reopen, and restart all hit the same throw. The
only fix is deleting the offending file from a terminal.
Root cause
The server dedupes entries before the normalization that creates the collision.
apps/server/src/workspace/WorkspaceSearchIndex.ts:281—withDirectoryAncestorsdedupes withnew Map(entries.map((entry) => [entry.path, entry])). The index reports both files, and"pkg/notes.txt" !== "pkg/notes.txt\n", so both survive.apps/server/src/workspace/WorkspaceSearchIndex.ts:435—list()sorts withlocaleCompare,which treats
\nas ignorable. The pair compares equal and lands adjacent.packages/contracts/src/project.ts:28—ProjectEntry.pathisTrimmedNonEmptyString, andpackages/contracts/src/baseSchemas.ts:6runsvalue.trim()on both decode and encode. Thetrailing newline is stripped here, so the client receives two byte-identical, adjacent paths.
apps/web/src/components/files/FileBrowserPanel.tsx:118maps them intotreePathsand:262calls
model.resetPaths(treePaths).@pierre/trees' path-store builder throws on itsadjacent-duplicate guard (
dist/path-store/src/builder.js:383).Confirmed against the library in isolation with exactly the paths the client receives:
Failing test
Drop this in
apps/server/src/workspace/and runbun run test src/workspace/WorkspaceSearchIndex.duplicatePathRepro.test.tsfromapps/server:Current output:
Suggested fix
Normalize in
toProjectEntry(WorkspaceSearchIndex.ts:138) sowithDirectoryAncestorsdedupes onthe same string the client will see. That is the actual bug and it is a one-line change.
Optionally also drop adjacent duplicates when building
treePaths(
FileBrowserPanel.tsx:118), so a single odd filename can never take down the whole panel again.Impact
Major degradation or frequent failure — the Files panel is completely unusable for the affected
workspace until the file is deleted outside the app.
Version or commit
main @ a483337a0Environment
macOS 15 (Darwin 25.5.0), Apple Silicon, desktop app.
Workaround
Delete the whitespace-suffixed file:
python3 -c "import os; os.remove('pkg/notes.txt\n')"To find offenders in a workspace: