fix(editor): reconstruct moved path by segment count (WSL alias / duplicate-separator safe)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-21 00:33:31 -07:00
co-authored by Orca
parent 1498ee9c99
commit dbabd94fe9
2 changed files with 89 additions and 7 deletions
@@ -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'
@@ -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/<distro>) 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