mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
fff1769bd1
commit
ceb179163d
4 changed files with 80 additions and 9 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: [] }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue