fix(app): normalize Windows paths for consistent project matching

On Windows, paths can use either forward slashes (C:/Users/...) or
backslashes (C:\Users\...). Deep links from external apps like GitButler
use forward slashes, while paths from the file system may use backslashes.
This caused duplicate project entries and broken ~ display.

- Add normalizePath() to convert backslashes to forward slashes
- Normalize paths in project open/close/expand/collapse/move/touch
- Fix ~ home directory display by normalizing before comparison
- Normalize paths at entry points (openProject, navigateToProject)
This commit is contained in:
Hegyi Áron Ferenc
2026-02-01 19:26:33 +01:00
parent 1798af72b0
commit fbea9a9103
4 changed files with 51 additions and 20 deletions

View File

@@ -3,7 +3,7 @@ import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { useServer, normalizePath } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
@@ -415,7 +415,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
list,
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
const normalized = normalizePath(root)
if (server.projects.list().find((x) => normalizePath(x.worktree) === normalized)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
},

View File

@@ -7,6 +7,10 @@ import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
export function normalizePath(input: string) {
return input.replaceAll("\\", "/")
}
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
@@ -163,39 +167,44 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
open(directory: string) {
const key = origin()
if (!key) return
const normalized = normalizePath(directory)
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
if (current.find((x) => normalizePath(x.worktree) === normalized)) return
setStore("projects", key, [{ worktree: normalized, expanded: true }, ...current])
},
close(directory: string) {
const key = origin()
if (!key) return
const normalized = normalizePath(directory)
const current = store.projects[key] ?? []
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
current.filter((x) => normalizePath(x.worktree) !== normalized),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const normalized = normalizePath(directory)
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
const index = current.findIndex((x) => normalizePath(x.worktree) === normalized)
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const normalized = normalizePath(directory)
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
const index = current.findIndex((x) => normalizePath(x.worktree) === normalized)
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const normalized = normalizePath(directory)
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
const fromIndex = current.findIndex((x) => normalizePath(x.worktree) === normalized)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
@@ -210,7 +219,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
const normalized = normalizePath(directory)
setStore("lastProject", key, normalized)
},
},
}

View File

@@ -10,7 +10,7 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { useServer, normalizePath } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -23,6 +23,16 @@ export default function Home() {
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const displayPath = (path: string) => {
const home = homedir()
if (!home) return path
const normalizedPath = normalizePath(path)
const normalizedHome = normalizePath(home)
if (normalizedPath.startsWith(normalizedHome)) {
return "~" + normalizedPath.slice(normalizedHome.length)
}
return path
}
const recent = createMemo(() => {
return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
@@ -97,7 +107,7 @@ export default function Home() {
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
{displayPath(project.worktree)}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>

View File

@@ -71,7 +71,7 @@ import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useServer, normalizePath } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
export default function Layout(props: ParentProps) {
@@ -1111,13 +1111,14 @@ export default function Layout(props: ParentProps) {
function navigateToProject(directory: string | undefined) {
if (!directory) return
const normalized = normalizePath(directory)
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
server.projects.touch(directory)
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
server.projects.touch(normalized)
const lastSession = store.lastSession[normalized]
navigate(`/${base64Encode(normalized)}${lastSession ? `/session/${lastSession}` : ""}`)
layout.mobileSidebar.hide()
}
@@ -1132,8 +1133,9 @@ export default function Layout(props: ParentProps) {
}
function openProject(directory: string, navigate = true) {
layout.projects.open(directory)
if (navigate) navigateToProject(directory)
const normalized = normalizePath(directory)
layout.projects.open(normalized)
if (navigate) navigateToProject(normalized)
}
const deepLinkEvent = "opencode:deep-link"
@@ -2536,6 +2538,16 @@ export default function Layout(props: ParentProps) {
return layout.sidebar.workspaces(project.worktree)()
})
const homedir = createMemo(() => globalSync.data.path.home)
const displayPath = (path: string) => {
const home = homedir()
if (!home) return path
const normalizedPath = normalizePath(path)
const normalizedHome = normalizePath(home)
if (normalizedPath.startsWith(normalizedHome)) {
return "~" + normalizedPath.slice(normalizedHome.length)
}
return path
}
return (
<div
@@ -2570,9 +2582,7 @@ export default function Layout(props: ParentProps) {
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p.worktree.replace(homedir(), "~")}
</span>
<span class="text-12-regular text-text-base truncate select-text">{displayPath(p.worktree)}</span>
</Tooltip>
</div>