mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-17 22:24:29 +00:00
app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
@@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveSlug(slug: string) {
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
const resolved = await resolveDirectory(directory)
|
||||||
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitDir(page: Page, directory: string) {
|
||||||
|
const target = await resolveDirectory(directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
return resolveSlug(slug)
|
||||||
|
.then((item) => item.directory)
|
||||||
|
.catch(() => "")
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
@@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
|
||||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
|
||||||
// against the resolved workspace slug.
|
|
||||||
await waitSlug(page)
|
await waitSlug(page)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
await waitDir(page, space)
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click()
|
await rootButton.click()
|
||||||
|
|
||||||
|
await waitDir(page, space)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const row = page.locator(item(space)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await row.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
|
||||||
return { slug, directory }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
|
||||||
await waitWorkspaceReady(page, slug)
|
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click({ force: true })
|
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||||
const next = await openWorkspaceNewSession(page, slug)
|
await waitWorkspaceReady(page, space)
|
||||||
|
|
||||||
|
const row = page.locator(item(space)).first()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const next = page.locator(button(space)).first()
|
||||||
|
await expect(next).toBeVisible()
|
||||||
|
await next.click({ force: true })
|
||||||
|
|
||||||
|
return waitDir(page, space.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFromWorkspace(
|
||||||
|
page: Page,
|
||||||
|
space: { slug: string; raw: string; directory: string },
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
const next = await openWorkspaceNewSession(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
await prompt.press("Enter")
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
await waitDir(page, next.directory)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return { sessionID, slug: next }
|
return { sessionID, slug: next.slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
|||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, root, [])
|
||||||
trackDirectory(first.directory)
|
trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -13,8 +12,10 @@ import {
|
|||||||
confirmDialog,
|
confirmDialog,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
|
resolveSlug,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
slugFromUrl,
|
slugFromUrl,
|
||||||
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
@@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
return { rootSlug, slug, directory: dir }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||||
@@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const workspaceSlug = await waitSlug(page, [slug])
|
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await cleanupTestProject(workspaceDir)
|
await cleanupTestProject(next.directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug, prev])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push(next)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
@@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
return next
|
||||||
return { slug, directory }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitWorkspace(page: Page, slug: string) {
|
async function waitWorkspace(page: Page, slug: string) {
|
||||||
@@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
return currentDir(page)
|
return currentDir(page)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,21 +46,13 @@ import Layout from "@/pages/layout"
|
|||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
const HomeRoute = () => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Suspense fallback={<Loading />}>
|
<Session />
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) {
|
|||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
{props.appChildren}
|
<Suspense fallback={<Loading />}>
|
||||||
{props.children}
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</Suspense>
|
||||||
</AppShellProviders>
|
</AppShellProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { createStore } from "solid-js/store"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
|
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const directory = createMemo(() => decode64(params.dir) ?? "")
|
const navigate = useNavigate()
|
||||||
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
let invalid = ""
|
||||||
|
|
||||||
createEffect(() => {
|
const [resolved] = createResource(
|
||||||
if (!params.dir) return
|
() => {
|
||||||
const raw = directory()
|
if (params.dir) return [location.pathname, params.dir] as const
|
||||||
if (!raw) {
|
},
|
||||||
if (state.invalid === params.dir) return
|
async ([pathname, b64Dir]) => {
|
||||||
setState("invalid", params.dir)
|
const directory = decode64(b64Dir)
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: language.t("directory.error.invalidUrl"),
|
|
||||||
})
|
|
||||||
navigate("/", { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = params.dir
|
if (!directory) {
|
||||||
globalSDK
|
if (invalid === params.dir) return
|
||||||
.createClient({
|
invalid = b64Dir
|
||||||
directory: raw,
|
showToast({
|
||||||
throwOnError: true,
|
variant: "error",
|
||||||
})
|
title: language.t("common.requestFailed"),
|
||||||
.path.get()
|
description: language.t("directory.error.invalidUrl"),
|
||||||
.then((x) => {
|
|
||||||
if (params.dir !== current) return
|
|
||||||
const next = x.data?.directory ?? raw
|
|
||||||
batch(() => {
|
|
||||||
setState("invalid", "")
|
|
||||||
setState("resolved", next)
|
|
||||||
})
|
})
|
||||||
if (next === raw) return
|
navigate("/", { replace: true })
|
||||||
const path = location.pathname.slice(current.length + 1)
|
return
|
||||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
}
|
||||||
})
|
|
||||||
.catch(() => {
|
return await globalSDK
|
||||||
if (params.dir !== current) return
|
.createClient({
|
||||||
batch(() => {
|
directory,
|
||||||
setState("invalid", "")
|
throwOnError: true,
|
||||||
setState("resolved", raw)
|
|
||||||
})
|
})
|
||||||
})
|
.path.get()
|
||||||
})
|
.then((x) => {
|
||||||
|
const next = x.data?.directory ?? directory
|
||||||
|
invalid = ""
|
||||||
|
if (next === directory) return next
|
||||||
|
const path = pathname.slice(b64Dir.length + 1)
|
||||||
|
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
invalid = ""
|
||||||
|
return directory
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={state.resolved} keyed>
|
<Show when={resolved()} keyed>
|
||||||
{(resolved) => (
|
{(resolved) => (
|
||||||
<SDKProvider directory={() => resolved}>
|
<SDKProvider directory={() => resolved}>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
|
|||||||
@@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
const currentProject = createMemo(() => {
|
const currentProject = createMemo(() => {
|
||||||
const directory = currentDir()
|
const directory = currentDir()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
const key = workspaceKey(directory)
|
||||||
|
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
|
|
||||||
const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
|
const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
|
||||||
if (sandbox) return sandbox
|
if (sandbox) return sandbox
|
||||||
|
|
||||||
const direct = projects.find((p) => p.worktree === directory)
|
const direct = projects.find((p) => workspaceKey(p.worktree) === key)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
const [child] = globalSync.child(directory, { bootstrap: false })
|
||||||
@@ -630,7 +631,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
|
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
|
||||||
if (!expanded) continue
|
if (!expanded) continue
|
||||||
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
const key = workspaceKey(directory)
|
||||||
|
const project = projects.find(
|
||||||
|
(item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
|
||||||
|
)
|
||||||
if (!project) continue
|
if (!project) continue
|
||||||
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
||||||
setStore("workspaceExpanded", directory, false)
|
setStore("workspaceExpanded", directory, false)
|
||||||
@@ -1155,13 +1159,16 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectRoot(directory: string) {
|
function projectRoot(directory: string) {
|
||||||
|
const key = workspaceKey(directory)
|
||||||
const project = layout.projects
|
const project = layout.projects
|
||||||
.list()
|
.list()
|
||||||
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
.find(
|
||||||
|
(item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
|
||||||
|
)
|
||||||
if (project) return project.worktree
|
if (project) return project.worktree
|
||||||
|
|
||||||
const known = Object.entries(store.workspaceOrder).find(
|
const known = Object.entries(store.workspaceOrder).find(
|
||||||
([root, dirs]) => root === directory || dirs.includes(directory),
|
([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
|
||||||
)
|
)
|
||||||
if (known) return known[0]
|
if (known) return known[0]
|
||||||
|
|
||||||
@@ -1177,13 +1184,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
return currentProject()?.worktree ?? projectRoot(directory)
|
return currentProject()?.worktree ?? projectRoot(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
function touchProjectRoute() {
|
|
||||||
const root = currentProject()?.worktree
|
|
||||||
if (!root) return
|
|
||||||
if (server.projects.last() !== root) server.projects.touch(root)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||||
return root
|
return root
|
||||||
@@ -1347,8 +1347,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
function closeProject(directory: string) {
|
function closeProject(directory: string) {
|
||||||
const list = layout.projects.list()
|
const list = layout.projects.list()
|
||||||
const index = list.findIndex((x) => x.worktree === directory)
|
const key = workspaceKey(directory)
|
||||||
const active = currentProject()?.worktree === directory
|
const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
|
||||||
|
const active = workspaceKey(currentProject()?.worktree ?? "") === key
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
const next = list[index + 1]
|
const next = list[index + 1]
|
||||||
|
|
||||||
@@ -1683,38 +1684,55 @@ export default function Layout(props: ParentProps) {
|
|||||||
const activeRoute = {
|
const activeRoute = {
|
||||||
session: "",
|
session: "",
|
||||||
sessionProject: "",
|
sessionProject: "",
|
||||||
|
directory: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
() => {
|
||||||
([ready, dir, id]) => {
|
const dir = params.dir
|
||||||
if (!ready || !dir) {
|
const directory = dir ? decode64(dir) : undefined
|
||||||
|
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
|
||||||
|
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
|
||||||
|
},
|
||||||
|
([ready, dir, id, root, directory, resolved]) => {
|
||||||
|
if (!ready || !dir || !directory) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
|
activeRoute.directory = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = decode64(dir)
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
|
activeRoute.directory = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = resolved || directory
|
||||||
const session = `${dir}/${id}`
|
const session = `${dir}/${id}`
|
||||||
if (session !== activeRoute.session) {
|
|
||||||
|
if (!root) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
|
||||||
|
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
||||||
|
if (changed) {
|
||||||
|
activeRoute.session = session
|
||||||
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root === activeRoute.sessionProject) return
|
if (root === activeRoute.sessionProject) return
|
||||||
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1778,8 +1796,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
const local = project.worktree
|
const local = project.worktree
|
||||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||||
const active = currentProject()
|
const active = currentProject()
|
||||||
const directory = active?.worktree === project.worktree ? currentDir() : undefined
|
const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
|
||||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
const extra =
|
||||||
|
directory &&
|
||||||
|
workspaceKey(directory) !== workspaceKey(local) &&
|
||||||
|
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
|
||||||
|
? directory
|
||||||
|
: undefined
|
||||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||||
|
|
||||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||||
|
|||||||
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
|
|||||||
describe("layout workspace helpers", () => {
|
describe("layout workspace helpers", () => {
|
||||||
test("normalizes trailing slash in workspace key", () => {
|
test("normalizes trailing slash in workspace key", () => {
|
||||||
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
|
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
|
||||||
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
|
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves posix and drive roots in workspace key", () => {
|
test("preserves posix and drive roots in workspace key", () => {
|
||||||
expect(workspaceKey("/")).toBe("/")
|
expect(workspaceKey("/")).toBe("/")
|
||||||
expect(workspaceKey("///")).toBe("/")
|
expect(workspaceKey("///")).toBe("/")
|
||||||
expect(workspaceKey("C:\\")).toBe("C:\\")
|
expect(workspaceKey("C:\\")).toBe("C:/")
|
||||||
expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
|
expect(workspaceKey("C://")).toBe("C:/")
|
||||||
expect(workspaceKey("C:///")).toBe("C:/")
|
expect(workspaceKey("C:///")).toBe("C:/")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type SessionStore = {
|
||||||
|
session?: Session[]
|
||||||
|
path: { directory: string }
|
||||||
|
}
|
||||||
|
|
||||||
export const workspaceKey = (directory: string) => {
|
export const workspaceKey = (directory: string) => {
|
||||||
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
|
const value = directory.replaceAll("\\", "/")
|
||||||
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
|
const drive = value.match(/^([A-Za-z]:)\/+$/)
|
||||||
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
|
if (drive) return `${drive[1]}/`
|
||||||
return directory.replace(/[\\/]+$/, "")
|
if (/^\/+$/i.test(value)) return "/"
|
||||||
|
return value.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortSessions(now: number) {
|
function sortSessions(now: number) {
|
||||||
@@ -25,13 +31,11 @@ function sortSessions(now: number) {
|
|||||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||||
|
|
||||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
|
||||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
|
|
||||||
|
|
||||||
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
|
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
|
||||||
stores
|
|
||||||
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
|
export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||||
.sort(sortSessions(now))[0]
|
|
||||||
|
|
||||||
export function hasProjectPermissions<T>(
|
export function hasProjectPermissions<T>(
|
||||||
request: Record<string, T[] | undefined>,
|
request: Record<string, T[] | undefined>,
|
||||||
@@ -40,9 +44,9 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request).some((list) => list?.some(include))
|
return Object.values(request).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[]) => {
|
export const childMapByParent = (sessions: Session[] | undefined) => {
|
||||||
const map = new Map<string, string[]>()
|
const map = new Map<string, string[]>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions ?? []) {
|
||||||
if (!session.parentID) continue
|
if (!session.parentID) continue
|
||||||
const existing = map.get(session.parentID)
|
const existing = map.get(session.parentID)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -332,12 +332,13 @@ export const SortableWorkspace = (props: {
|
|||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||||
const boot = createMemo(() => open() || active())
|
const boot = createMemo(() => open() || active())
|
||||||
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||||
const wasBusy = createMemo((prev) => prev || busy(), false)
|
const wasBusy = createMemo((prev) => prev || busy(), false)
|
||||||
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
|
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
|
||||||
const touch = createMediaQuery("(hover: none)")
|
const touch = createMediaQuery("(hover: none)")
|
||||||
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.directory)
|
await globalSync.project.loadSessions(props.directory)
|
||||||
@@ -472,8 +473,9 @@ export const LocalWorkspace = (props: {
|
|||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspace().store.session))
|
const children = createMemo(() => childMapByParent(workspace().store.session))
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||||
const loading = createMemo(() => !booted() && sessions().length === 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
|
const loading = createMemo(() => !booted() && count() === 0)
|
||||||
|
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
|
|||||||
Reference in New Issue
Block a user