Skip to content

fix(path-decode): recover project paths with hyphenated middle segments - #30

Open
playmaker wants to merge 1 commit into
lovstudio:mainfrom
playmaker:fix/decode-project-path-middle-merge
Open

fix(path-decode): recover project paths with hyphenated middle segments#30
playmaker wants to merge 1 commit into
lovstudio:mainfrom
playmaker:fix/decode-project-path-middle-merge

Conversation

@playmaker

@playmaker playmaker commented Jul 23, 2026

Copy link
Copy Markdown

Bug

Lovcode's project list and session display show wrong paths for many projects. Two compounding causes:

  1. Wrong encoder. encode_project_path in src-tauri/src/app/session_listing.rs:10 only handles two characters (/.--, /-). Claude Code's real encoder, sanitizePath in src/utils/sessionStoragePortable.ts:311, replaces every non-alphanumeric character with -. So any user who pointed Claude Code at a project whose path contains ., _, , :, etc. ended up with two divergent project-id streams that no longer matched each other — Lovcode's writes and Claude Code's writes for the same project landed in different ~/.claude/projects/<id>/ directories. Subagent research on the claude-code source confirms: every character in /[^a-zA-Z0-9]/g is replaced.

  2. Wrong primary display source. Lovcode's list_projects resolved each project's display path by calling decode_project_path(project_id) — a heuristic that tries to invert the encoding. Claude Code never decodes; it reads cwd directly from the first session_meta line of each session jsonl, where Claude Code writes the canonical path verbatim. The encoding is fundamentally lossy (a real /path/<a>-<b> and /path/<a>_<b> collapse to the same id), so any heuristic will produce wrong paths for some layouts. The right display source is the jsonl's cwd.

The same flaws break worktree layouts (<dashed-parent>/.worktrees/<dashed-leaf>/) and any project whose path contains non-alphanumeric characters in non-leading positions.

Fix

Four coordinated changes:

1. encode_project_path — match Claude Code's algorithm

Rewrite the encoder to mirror sanitizePath:

pub(crate) fn encode_project_path(path: &str) -> String {
    const MAX_LEN: usize = 200;
    let sanitized: String = path
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
        .collect();
    if sanitized.len() <= MAX_LEN {
        return sanitized;
    }
    format!("{}-{}", &sanitized[..MAX_LEN], djb2_base36(path))
}

For paths ≤ 200 chars: pure regex replacement. For longer paths: truncate and append a djb2 base-36 hash. Claude Code picks Bun.hash (wyhash) under Bun or simpleHash (djb2) under Node; Lovcode only runs in the desktop Tauri app (no Bun), so we always use djb2, which matches what findProjectDir in Claude Code falls back to for cross-runtime lookups.

2. list_projects — read cwd from jsonl as primary path

For each project_dir, find the newest session jsonl (by mtime), read its head with read_session_head, and use head.cwd as the display path. Fall back to decode_project_path only when no parseable session exists yet (rare edge case: a project dir created but never written to).

This is the same approach Claude Code uses internally (parseSessionInfoFromLite in src/utils/listSessionsImpl.ts:127-128), and it's lossless because Claude Code writes the canonical cwd verbatim into every session's first line.

3. Project-level cwd fallback in compute_all_sessions and collect_lightweight_sessions_snapshot

A second related issue surfaced after (1) and (2) shipped: some sessions have no cwd line in their jsonl head — typically tail-only snapshots that Claude Code appends at session start (just last-prompt/mode/permission-mode, a few hundred bytes, no user-message line). For these sessions, read_session_head(...).cwd is None, and the previous code fell back to the lossy decode_project_path heuristic.

The fix: pre-scan every project_dir and build a HashMap<project_id, project_cwd> by reading the cwd from any jsonl in that project_dir that has one (not just the newest — if the newest happens to be a cwd-less tail snapshot, we'd still find cwd in an earlier fuller session). Both compute_all_sessions (pass1/pass2 fallback) and collect_lightweight_sessions_snapshot consult this map.

The fallback chain is now:

  1. head.cwd (per-session, read from this jsonl's head)
  2. project_cwd_map[project_id] (from any other session under the same project_id)
  3. decode_project_path(project_id) (the lossy heuristic, last resort)

Sessions within a project_dir all share the same cwd in Claude Code's encoding, so (2) is correct whenever (1) fails.

4. try_merge_segments — keep 1-block as a fallback, drop the speculative 2-block extension

With cwd-from-jsonl as primary, the heuristic only runs when no jsonl exists yet. The 2-block extension that we initially added (to cover worktree layouts like <dashed-parent>/.worktrees/<dashed-leaf>/) is no longer needed — those cases are now resolved by reading the jsonl. The function reverts to enumerating one merge block, which is sufficient for the rare fallback case.

5. SESSIONS_CACHE_VERSION bumped 6 → 7 (one above main)

~/.lovstudio/lovcode/sessions-cache.json persists a fully-decoded Session per jsonl file, including project_path. The old cache was built with the broken decoder, so even after upgrading the binary the UI keeps showing the wrong paths until each cached jsonl file is touched (size/mtime change) and re-read.

Bumping the version makes load_sessions_cache silently discard v6 entries on next launch, so the upgraded binary repopulates the cache using the fixed decoder + cwd-from-jsonl.

Tests

Ten unit tests across app::session_listing::tests and app::session_cache::tests against real temp directories:

  • try_merge_segments_recovers_middle_merge — 1-block middle case (<parent>/<name>)
  • try_merge_segments_recovers_prefix_merge — old prefix-merge preserved
  • try_merge_segments_recovers_all_merged — old "all merged" preserved
  • try_merge_segments_returns_none_when_no_match — non-matching path returns None
  • encode_project_path_replaces_all_non_alphanumeric — matches Claude Code's regex exactly (covers /, ., _, etc.)
  • encode_project_path_truncates_long_paths_with_hash — >200-char paths get truncated + hash
  • encode_project_path_then_decode_with_heuristic_finds_real_path — end-to-end encode → decode round-trip
  • project_cwd_map_picks_cwd_from_any_session_in_project — even when the newest jsonl is a cwd-less tail snapshot, the map finds cwd from an earlier fuller session
  • tail_only_session_falls_back_to_project_cwd — end-to-end: head-less session falls back to project-level cwd via the map

All ten pass: cargo test --lib → 10 passed.

Upgrade instructions

After pulling this change, on first launch the existing sessions-cache.json is discarded automatically (version mismatch). To force it manually:

mv ~/.lovstudio/lovcode/sessions-cache.json ~/.lovstudio/lovcode/sessions-cache.v6.bak

Then restart the app. Each project path is re-decoded using the fixed algorithm, and projects with at least one session will display the canonical cwd from the jsonl directly.

Limitations

Claude Code's encoding is fundamentally lossy: _ and - are not distinguished, so e.g. /path/<a>-<b> and /path/<a>_<b> would both encode to the same project id. With cwd-from-jsonl as primary, this ambiguity is moot for projects that have at least one session — Claude Code wrote the real path into the jsonl. The lossy heuristic only matters for project dirs without parseable sessions, which is rare.

A canonical-path lookup table (mapping project_id → real path) would be the proper long-term fix for the empty-project edge case; out of scope here.

@playmaker
playmaker force-pushed the fix/decode-project-path-middle-merge branch 7 times, most recently from e0b17fd to 09c5eca Compare July 23, 2026 08:14
The previous try_merge_segments only merged a *prefix* of segments into one
dash-joined chunk, leaving the rest as / separators. That misses any path
whose dash lives in the middle, e.g. `/srv/repos/<dashed-name>/` decoded to
`/srv/repos/<a>/<b>` because no candidate kept the parent as `/` while
merging `<a>` and `<b>` with `-`.

Rewrite the inner loop to enumerate every contiguous [start, end) segment
range and merge only that range with '-'. This subsumes the old behavior
(all-merge, prefix-merge, tail-merge) and adds the missing middle-merge
case. Tests cover the three surviving configurations plus a no-match case.

Co-Authored-By: Claude <noreply@anthropic.com>
@playmaker
playmaker force-pushed the fix/decode-project-path-middle-merge branch 2 times, most recently from 09c5eca to d1c054e Compare July 23, 2026 08:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant