[Bug]: Files panel throws "Duplicate path" and renders nothing when a workspace has two filenames differing only by trailing whitespace
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
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-reproin 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—withDirectoryAncestorsdedupes 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 withlocaleCompare,
which treats\nas ignorable. The pair compares equal and lands adjacent.packages/contracts/src/project.ts:28—ProjectEntry.pathisTrimmedNonEmptyString, and
packages/contracts/src/baseSchemas.ts:6runsvalue.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:118maps them intotreePathsand:262
callsmodel.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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/server/src/workspace/WorkspaceSearchIndex.ts at toProjectEntry and withDirectoryAncestors, then run the provided duplicatePathRepro test from apps/server. Ensure entries that collapse after ProjectEntry encoding are not emitted twice, and verify the test passes so the Files panel remains usable.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100