From dbabd94fe9f1ca8f00a158bab1509155e927da77 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:33:31 -0700 Subject: [PATCH] fix(editor): reconstruct moved path by segment count (WSL alias / duplicate-separator safe) Co-authored-by: Orca --- ...p-open-editor-tabs-for-path-change.test.ts | 56 +++++++++++++++++++ .../remap-open-editor-tabs-for-path-change.ts | 40 ++++++++++--- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts index 8ca272d2699..0dca0a28db9 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts @@ -250,6 +250,62 @@ describe('remapOpenEditorTabsForPathChange', () => { expect(useAppStore.getState().editorDrafts[moved.id]).toBe('dirty windows work') }) + it('rebuilds the moved path across WSL UNC aliases without fabricating segments', () => { + // The shared matcher equates \\wsl$\Ubuntu and \\wsl.localhost\ubuntu (same + // filesystem), but they differ in raw length — a raw slice(fromPath.length) + // would corrupt the destination. Segment-count reconstruction must not. + const state = useAppStore.getState() + state.openFile( + { + filePath: '\\\\wsl.localhost\\ubuntu\\repo\\src\\a.ts', + relativePath: 'src\\a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, 'wsl draft') + state.markFileDirty(id, true) + + const result = remapOpenEditorTabsForPathChange({ + fromPath: '\\\\wsl$\\Ubuntu\\repo\\src', + toPath: '\\\\wsl$\\Ubuntu\\repo\\dst', + worktreePath: '\\\\wsl$\\Ubuntu\\repo', + worktreeId: 'wt-1' + }) + + expect(result.ok).toBe(true) + const moved = useAppStore.getState().openFiles[0]! + expect(moved.filePath).toBe('\\\\wsl$\\Ubuntu\\repo\\dst\\a.ts') + expect(useAppStore.getState().editorDrafts[moved.id]).toBe('wsl draft') + }) + + it('rebuilds the moved path when the source root has duplicate separators', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: 'C:\\Repo\\Src\\deep\\a.ts', + relativePath: 'Src\\deep\\a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: 'C:\\Repo\\\\Src', + toPath: 'C:\\Repo\\Dst', + worktreePath: 'C:\\Repo', + worktreeId: 'wt-1' + }) + + // Nested suffix preserved, no dropped or doubled separator. + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('C:\\Repo\\Dst\\deep\\a.ts') + }) + it('clears the untitled marker when remapping a renamed new markdown file', () => { const state = useAppStore.getState() const oldPath = '/repo/untitled.md' diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index e32ce877056..ade7f07b8ff 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts @@ -63,10 +63,14 @@ function splitAbsolutePath(path: string): { prefix: string; segments: string[] } } if (normalized.startsWith('//')) { const segments = normalized.slice(2).split('/').filter(Boolean) - return { - prefix: `//${segments.slice(0, 2).join('/').toLowerCase()}`, - segments: segments.slice(2) - } + const server = (segments[0] ?? '').toLowerCase() + // Canonicalize the two WSL UNC aliases to one identity (matches the shared + // normalizer) so `\\wsl$\Distro` and `\\wsl.localhost\Distro` compare equal. + const prefix = + server === 'wsl.localhost' || server === 'wsl$' + ? `//wsl/${(segments[1] ?? '').toLowerCase()}` + : `//${segments.slice(0, 2).join('/').toLowerCase()}` + return { prefix, segments: segments.slice(2) } } if (normalized.startsWith('/')) { return { prefix: '/', segments: normalized.slice(1).split('/').filter(Boolean) } @@ -74,6 +78,28 @@ function splitAbsolutePath(path: string): { prefix: string; segments: string[] } return { prefix: '', segments: normalized.split('/').filter(Boolean) } } +// Rebuild a moved descendant's path WITHOUT raw-length slicing. The shared +// containment check matches paths equal under normalization but differing in raw +// length (wsl$ vs wsl.localhost aliases, duplicate/trailing separators), so +// `slice(fromPath.length)` would fabricate a path. Derive the suffix by segment +// count from the normalized relative path, then re-emit the ORIGINAL-case tail of +// filePath onto toPath in toPath's separator style. +function computeMovedPath(fromPath: string, toPath: string, filePath: string): string { + const suffix = relativePathInsideRoot(fromPath, filePath) + if (suffix === null) { + // Selection already matched, so this is unreachable; degrade to the raw splice. + return toPath + filePath.slice(fromPath.length) + } + if (suffix === '') { + return toPath + } + const segmentCount = suffix.split('/').filter(Boolean).length + const rawSegments = filePath.split(/[\\/]+/).filter(Boolean) + const tail = rawSegments.slice(rawSegments.length - segmentCount) + const separator = toPath.includes('\\') ? '\\' : '/' + return `${toPath.replace(/[\\/]+$/, '')}${separator}${tail.join(separator)}` +} + function getRelativePathFromRoot(rootPath: string, candidatePath: string): string { const insideRoot = relativePathInsideRoot(rootPath, candidatePath) if (insideRoot !== null) { @@ -88,10 +114,10 @@ function getRelativePathFromRoot(rootPath: string, candidatePath: string): strin // Windows drive/UNC segments are case-insensitive, so a case-only difference // is the SAME directory and must not emit a spurious `..`. The WSL Linux suffix - // (prefix //wsl…) stays case-sensitive; POSIX roots stay case-sensitive. + // (canonical prefix //wsl/) stays case-sensitive, as do POSIX roots. const foldCase = /^[a-z]:$/i.test(root.prefix) || - (root.prefix.startsWith('//') && !root.prefix.startsWith('//wsl')) + (root.prefix.startsWith('//') && !root.prefix.startsWith('//wsl/')) const sameSegment = (a: string, b: string): boolean => foldCase ? a.toLowerCase() === b.toLowerCase() : a === b @@ -173,7 +199,7 @@ export function remapOpenEditorTabsForPathChange({ // close+reopen: preserves the full OpenFile + all id-keyed state and closes // the watcher-race window that close/reopen opened. const updatedPathOf = (file: { filePath: string }): string => - toPath + file.filePath.slice(fromPath.length) + computeMovedPath(fromPath, toPath, file.filePath) const relativeOf = (file: { filePath: string relativePath: string