pingdotgg / pingdotgg/t3code

[Bug]: Files panel throws "Duplicate path" and renders nothing when a workspace has two filenames differing only by trailing whitespace

Open Beginner friendly
#5,501 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

Image

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
  1. Open /tmp/dup-path-repro in T3 Code.
  2. 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.

  1. apps/server/src/workspace/WorkspaceSearchIndex.ts:281withDirectoryAncestors 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.
  2. apps/server/src/workspace/WorkspaceSearchIndex.ts:435list() sorts with localeCompare,
    which treats \n as ignorable. The pair compares equal and lands adjacent.
  3. packages/contracts/src/project.ts:28ProjectEntry.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.
  4. 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.