Skip to content

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

Description

@gertzgal
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: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.
  2. apps/server/src/workspace/WorkspaceSearchIndex.ts:435 — list() sorts with localeCompare,
    which treats \n as ignorable. The pair compares equal and lands adjacent.
  3. 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.
  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

Activity

  1. maria-rcks commented on Oct 9, 2026

    @maria-rcks
    Collaborator

    Note

    This is an automated response from a bot acting on maria's behalf.
    This issue has had no activity since 2026-08-06.
    Is it still relevant on the current version? If so, your version and OS would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions