Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors"
import { workspaceKey } from "@/pages/layout/helpers"

type GlobalStore = {
ready: boolean
Expand Down Expand Up @@ -152,7 +153,7 @@ function createGlobalSync() {
const children = createChildStoreManager({
owner,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(workspaceKey(directory)),
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
Expand All @@ -178,7 +179,8 @@ function createGlobalSync() {
}

async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
const key = workspaceKey(directory)
const pending = sessionLoads.get(key)
if (pending) return pending

children.pin(directory)
Expand Down Expand Up @@ -236,9 +238,9 @@ function createGlobalSync() {
})
})

sessionLoads.set(directory, promise)
sessionLoads.set(key, promise)
promise.finally(() => {
sessionLoads.delete(directory)
sessionLoads.delete(key)
children.unpin(directory)
})
return promise
Expand All @@ -252,7 +254,7 @@ function createGlobalSync() {
children.pin(directory)
const promise = (async () => {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(directory)
const cache = children.vcsCache.get(workspaceKey(directory))
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
Expand Down Expand Up @@ -297,14 +299,15 @@ function createGlobalSync() {
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
for (const directory of children.canonicalDir.values()) {
queue.push(directory)
}
}
return
}

const existing = children.children[directory]
const key = workspaceKey(directory)
const existing = children.children[key]
if (!existing) return
children.mark(directory)
const [store, setStore] = existing
Expand All @@ -315,7 +318,7 @@ function createGlobalSync() {
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
vcsCache: children.vcsCache.get(workspaceKey(directory)),
loadLsp: () => {
sdkFor(directory)
.lsp.status()
Expand Down
71 changes: 42 additions & 29 deletions packages/app/src/context/global-sync/child-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { workspaceKey } from "@/pages/layout/helpers"

export function createChildStoreManager(input: {
owner: Owner
Expand All @@ -31,31 +32,35 @@ export function createChildStoreManager(input: {
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const canonicalDir = new Map<string, string>()

const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction(directory)
const k = workspaceKey(directory)
lifecycle.set(k, { lastAccessAt: Date.now() })
runEviction(k)
}

const pin = (directory: string) => {
if (!directory) return
pins.set(directory, (pins.get(directory) ?? 0) + 1)
const k = workspaceKey(directory)
pins.set(k, (pins.get(k) ?? 0) + 1)
mark(directory)
}

const unpin = (directory: string) => {
if (!directory) return
const next = (pins.get(directory) ?? 0) - 1
const k = workspaceKey(directory)
const next = (pins.get(k) ?? 0) - 1
if (next > 0) {
pins.set(directory, next)
pins.set(k, next)
return
}
pins.delete(directory)
pins.delete(k)
runEviction()
}

const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinned = (directory: string) => (pins.get(workspaceKey(directory)) ?? 0) > 0

const pinForOwner = (directory: string) => {
const current = getOwner()
Expand All @@ -78,29 +83,32 @@ export function createChildStoreManager(input: {
}

function disposeDirectory(directory: string) {
const key = workspaceKey(directory)
const orig = canonicalDir.get(key) ?? directory
if (
!canDisposeDirectory({
directory,
hasStore: !!children[directory],
directory: orig,
hasStore: !!children[key],
pinned: pinned(directory),
booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(directory),
booting: input.isBooting(orig),
loadingSessions: input.isLoadingSessions(orig),
})
) {
return false
}

vcsCache.delete(directory)
metaCache.delete(directory)
iconCache.delete(directory)
lifecycle.delete(directory)
const dispose = disposers.get(directory)
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
canonicalDir.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(directory)
disposers.delete(key)
}
delete children[directory]
input.onDispose(directory)
delete children[key]
input.onDispose(orig)
return true
}

Expand All @@ -123,7 +131,11 @@ export function createChildStoreManager(input: {

function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const key = workspaceKey(directory)
// Prefer backslash format on Windows for API calls that need server-side exact match.
// If backslash path is available, always use it as the canonical form.
if (!canonicalDir.has(key) || directory.includes("\\")) canonicalDir.set(key, directory)
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
Expand All @@ -132,7 +144,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })

const meta = runWithOwner(input.owner, () =>
persisted(
Expand All @@ -141,7 +153,7 @@ export function createChildStoreManager(input: {
),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })

const icon = runWithOwner(input.owner, () =>
persisted(
Expand All @@ -150,7 +162,7 @@ export function createChildStoreManager(input: {
),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })

const init = () =>
createRoot((dispose) => {
Expand Down Expand Up @@ -183,13 +195,13 @@ export function createChildStoreManager(input: {
message: {},
part: {},
})
children[directory] = child
disposers.set(directory, dispose)
children[key] = child
disposers.set(key, dispose)

const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[directory] !== child) return
if (children[key] !== child) return
run()
})
}
Expand All @@ -214,7 +226,7 @@ export function createChildStoreManager(input: {
runWithOwner(input.owner, init)
}
mark(directory)
const childStore = children[directory]
const childStore = children[key]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore
}
Expand All @@ -240,7 +252,7 @@ export function createChildStoreManager(input: {

function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
const cached = metaCache.get(workspaceKey(directory))
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
Expand All @@ -257,7 +269,7 @@ export function createChildStoreManager(input: {

function projectIcon(directory: string, value: string | undefined) {
const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(directory)
const cached = iconCache.get(workspaceKey(directory))
if (!cached) return
if (store.icon === value) return
cached.setStore("value", value)
Expand All @@ -266,6 +278,7 @@ export function createChildStoreManager(input: {

return {
children,
canonicalDir,
ensureChild,
child,
peek,
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived

const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
const roots = (store: SessionStore, dir?: string) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, dir ?? store.path.directory))

export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
export const sortedRootSessions = (store: SessionStore, now: number, dir?: string) =>
roots(store, dir).sort(sortSessions(now))

export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
stores.flatMap((s) => roots(s)).sort(sortSessions(now))[0]

export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined> | undefined,
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/pages/layout/sidebar-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,10 @@ export const SortableProject = (props: {
}

const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow(), props.project.worktree))
const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
return sortedRootSessions(data, props.sortNow(), directory)
}
const tile = () => (
<ProjectTile
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/pages/layout/sidebar-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export const SortableWorkspace = (props: {
pendingRename: false,
})
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow(), props.directory))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
Expand Down Expand Up @@ -451,7 +451,7 @@ export const LocalWorkspace = (props: {
return { store, setStore }
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow(), props.project.worktree))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0)
Expand Down
17 changes: 13 additions & 4 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ export namespace Session {
const parentTitlePrefix = "New session - "
const childTitlePrefix = "Child session - "

// NOTE: normalizeDirectory must stay in sync with workspaceKey() in packages/app/src/pages/layout/helpers.ts
const normalizeDirectory = (directory: string) => {
const value = directory.replaceAll("\\", "/")
const drive = value.match(/^([A-Za-z]:)\/+$/)
if (drive) return `${drive[1]}/`
if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
}

function createDefaultTitle(isChild = false) {
return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
}
Expand Down Expand Up @@ -71,7 +80,7 @@ export namespace Session {
slug: row.slug,
projectID: row.project_id,
workspaceID: row.workspace_id ?? undefined,
directory: row.directory,
directory: normalizeDirectory(row.directory),
parentID: row.parent_id ?? undefined,
title: row.title,
version: row.version,
Expand Down Expand Up @@ -389,7 +398,7 @@ export namespace Session {
slug: Slug.create(),
version: Installation.VERSION,
projectID: ctx.project.id,
directory: input.directory,
directory: normalizeDirectory(input.directory),
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
Expand Down Expand Up @@ -751,7 +760,7 @@ export namespace Session {
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
}
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory)))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
Expand Down Expand Up @@ -791,7 +800,7 @@ export namespace Session {
const conditions: SQL[] = []

if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory)))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
Expand Down
Loading