fix(desktop): mirror Windows path identity in live overlay + WSL spelling

Addresses @teknium1's review of #61950:

- The desktop live overlay (workspace-groups.ts) matched cwd membership
  case-sensitively, so a fresh mixed-case/separator Windows session missed
  its explicit/auto project until the next backend tree refresh. Mirror the
  backend identity (isWindowsPath/comparisonSegments/pathKey) in isPathUnder,
  liveSessionProjectId, and overlayRepoLanes lane matching. Comparison-only —
  emitted ids/labels keep their spelling. POSIX stays case-sensitive.
- Backend _is_windows_path missed root-relative `\wsl.localhost\...` (single
  leading backslash), leaving that historical spelling case-sensitive. Classify
  any backslash-rooted path as Windows.

Tests: WSL-spelling collapse + explicit-project precedence (project_tree),
Windows/WSL live-overlay membership + POSIX case-sensitivity (workspace-groups).
This commit is contained in:
Brooklyn Nicholson 2026-07-12 04:33:05 -04:00
parent fff1769bd1
commit ceb179163d
4 changed files with 80 additions and 9 deletions

View file

@ -494,6 +494,29 @@ describe('liveSessionProjectId', () => {
expect(id).toBe('p_app')
})
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
// The bug: a fresh Windows session drops into the overlay before the next
// backend refresh; case-sensitive matching missed its project until then.
const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])])
expect(id).toBe('p_notes')
})
it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => {
const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [
makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj'])
])
expect(id).toBe('p_proj')
})
it('keeps POSIX cwd matching case-sensitive (no false project match)', () => {
// Distinct case on POSIX is a distinct path → falls back to its own auto id.
expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe(
'/work/notes'
)
})
})
describe('overlayLiveLanes', () => {

View file

@ -73,6 +73,27 @@ const segments = (path: string): string[] =>
/** A path with trailing separators stripped, for stable equality checks. */
const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '')
// Windows spellings: drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any
// backslash-rooted path (`\wsl.localhost\…`). A single leading `/` stays POSIX.
// Mirrors the backend `_is_windows_path` so the live overlay places rows into
// the same project the backend tree would.
const isWindowsPath = (path: string): boolean =>
/^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\') || path.startsWith('//')
/**
* Segments for identity comparison: Windows paths fold case (and separators, via
* {@link segments}) so `C:\Work` and `c:/work` are one lane; POSIX stays
* case-sensitive. Comparison-only emitted ids/labels keep their spelling.
*/
const comparisonSegments = (path: string): string[] => {
const segs = segments(path)
return isWindowsPath(path) ? segs.map(seg => seg.toLowerCase()) : segs
}
/** Canonical per-host comparison key (separator/case/trailing-slash agnostic). */
const pathKey = (path: null | string | undefined): string => comparisonSegments(path ?? '').join('/')
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
@ -317,8 +338,8 @@ export function mergeRepoWorktreeGroups(
/** True when `target` equals `folder` or is nested under it (segment-wise). */
function isPathUnder(folder: string, target: string): boolean {
const f = segments(folder)
const t = segments(target)
const f = comparisonSegments(folder)
const t = comparisonSegments(target)
if (!f.length || f.length > t.length) {
return false
@ -347,9 +368,8 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
// No persisted repo root yet (brand-new session) → the cwd is the root.
const repoRoot = (session.git_repo_root || '').trim() || cwd
const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`)
if (!underRepo) {
if (!isPathUnder(repoRoot, cwd)) {
return null
}
@ -423,7 +443,7 @@ export function overlayRepoLanes(
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarWorkspaceTree {
const repoRoot = normalizePath(repo.path)
const repoRootKey = pathKey(repo.path)
let changed = false
// Snapshot lanes minus anything the user just deleted/archived.
@ -457,7 +477,7 @@ export function overlayRepoLanes(
for (const g of lanes) {
const lanePath = normalizePath(g.path)
if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) {
if (!lanePath || pathKey(lanePath) === repoRootKey || !isPathUnder(lanePath, cwd)) {
continue
}
@ -480,14 +500,14 @@ export function overlayRepoLanes(
continue
}
const placedPath = normalizePath(placed.path)
const placedKey = pathKey(placed.path)
lane =
lanes.find(g => g.id === placed.id) ??
(placed.isMain
? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase())
: undefined) ??
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
(!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined)
if (!lane) {
lane = { ...placed, sessions: [] }

View file

@ -237,6 +237,31 @@ def test_windows_path_identity_preserves_explicit_project_priority():
assert tree["scoped_session_ids"] == [session["id"]]
def test_wsl_localhost_cwds_collapse_into_one_auto_project():
# Root-relative WSL spellings (single leading backslash) are Windows paths,
# so case/separator variants collapse instead of spawning duplicate autos.
sessions = [
_session(r"\wsl.localhost\Ubuntu\home\alice\proj"),
_session("//wsl.localhost/Ubuntu/home/alice/PROJ"),
]
tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True)
assert len(tree["projects"]) == 1
assert tree["projects"][0]["sessionCount"] == 2
def test_wsl_localhost_path_cannot_bypass_explicit_project():
explicit = _project("p_proj", "Proj", [r"\wsl.localhost\Ubuntu\home\alice\proj"])
session = _session("//wsl.localhost/Ubuntu/home/alice/PROJ/")
tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True)
assert [p["id"] for p in tree["projects"]] == ["p_proj"]
assert tree["projects"][0]["sessionCount"] == 1
assert tree["scoped_session_ids"] == [session["id"]]
def test_posix_path_identity_remains_case_sensitive():
explicit = _project("p_notes", "Notes", ["/Work/Notes"])
session = _session("/work/notes")

View file

@ -62,7 +62,10 @@ def _segments(path: str) -> list[str]:
def _is_windows_path(path: str) -> bool:
value = (path or "").strip()
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\\\", "//"))
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
# — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A
# single leading `/` stays POSIX (case-sensitive).
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
def _comparison_segments(path: str) -> list[str]: