diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts index 4cac32fc6a6..a4e5f4ecd3c 100644 --- a/packages/app/e2e/regression/workspaces.spec.ts +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -83,11 +83,11 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await expect(page.getByRole("menuitem", { name: "Local repository" })).toBeVisible() await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible() await expect(page.getByRole("menuitem", { name: "Workspace", exact: true })).toBeVisible() - await expect(page.getByRole("menuitem", { name: "View all" })).toHaveCount(0) + await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible() await page.getByRole("menuitem", { name: "New workspace" }).click() await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() - await expect(page.getByText("main", { exact: true })).toBeVisible() + await expect(page.getByText("from main", { exact: true })).toBeVisible() await page.getByRole("button", { name: /New workspace/ }).click() await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover() @@ -95,6 +95,308 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await expect(page.getByRole("button", { name: /feature/ })).toBeVisible() }) +test("lists and manually deletes workspaces from settings", async ({ page }) => { + const draftID = "draft_workspace_settings" + const cleanWorkspace = `${workspace}-clean` + const inventory = { ...project, sandboxes: [workspace, cleanWorkspace] } + const session = { + id: "ses_workspace_settings", + slug: "workspace-settings", + projectID: project.id, + directory: workspace, + title: "Workspace settings session", + version: "dev", + time: { created: 1, updated: 2 }, + } + let removal: { directory: string | null; body: unknown } | undefined + let statusRequests = 0 + let sessionListRequests = 0 + page.on("request", (request) => { + const path = new URL(request.url()).pathname + if (path === "/vcs/status" || path === "/api/vcs/status") statusRequests++ + if (path === "/session" || path === "/api/session") sessionListRequests++ + }) + + await mockOpenCodeServer(page, { + directory: root, + project: inventory, + provider, + sessions: [session], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" }, + }) + return + } + if (route.request().method() !== "DELETE") return route.fallback() + const url = new URL(route.request().url()) + removal = { directory: url.searchParams.get("directory"), body: route.request().postDataJSON() } + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "true", + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "View all" }).click() + + const settings = page.locator(".settings-v2-dialog") + await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected") + await expect(settings.getByText(workspace, { exact: true })).toBeVisible() + await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible() + await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible() + await expect(settings.getByText("Automatically delete old workspaces")).toHaveCount(0) + const firstSessionInventory = sessionListRequests + await page.keyboard.press("Escape") + await expect(settings).toHaveCount(0) + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "View all" }).click() + await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible() + expect(sessionListRequests).toBeGreaterThan(firstSessionInventory) + await page.setViewportSize({ width: 375, height: 700 }) + expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + await page.setViewportSize({ width: 1280, height: 720 }) + + await settings.getByRole("button", { name: "More options" }).click() + await page.getByRole("menuitem", { name: "Delete all workspaces" }).click() + const bulkConfirmation = page.locator('[data-dialog-layer="1"]') + await expect(bulkConfirmation.getByText("Delete all 2 workspaces?")).toBeVisible() + await expect(bulkConfirmation).toContainText("All projects") + await bulkConfirmation.getByRole("button", { name: "Cancel" }).click() + + await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() + let confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) + await expect(confirmation).toContainText("linked sessions") + await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled() + const firstInspection = statusRequests + await confirmation.getByRole("button", { name: "Cancel" }).click() + + await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() + confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) + await expect(confirmation).toContainText("linked sessions") + expect(statusRequests).toBeGreaterThan(firstInspection) + await confirmation.getByRole("button", { name: "Cancel" }).click() + + await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click() + confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature-clean"?' }) + await expect(confirmation).toContainText("permanently removed") + const beforeRemoval = statusRequests + await confirmation.getByRole("button", { name: "Delete workspace" }).click() + await expect.poll(() => removal).toEqual({ directory: root, body: { directory: cleanWorkspace } }) + expect(statusRequests).toBeGreaterThan(beforeRemoval) + await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0) + await expect(settings.getByText(workspace, { exact: true })).toBeVisible() +}) + +test("blocks deletion of the currently active workspace", async ({ page }) => { + const draftID = "draft_workspace_active" + let removed = false + await mockOpenCodeServer(page, { + directory: workspace, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() !== "DELETE") return route.fallback() + removed = true + await route.fulfill({ status: 200, contentType: "application/json", body: "true" }) + }) + await init(page, { type: "draft", draftID, directory: workspace }) + + await page.goto(`/new-session?draftId=${draftID}`) + await page.getByRole("button", { name: /feature/ }).click() + await page.getByRole("menuitem", { name: "View all" }).click() + const settings = page.locator(".settings-v2-dialog") + await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() + const confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) + await expect(confirmation).toContainText("active workspace") + await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled() + expect(removed).toBe(false) +}) + +test("wraps the workspace toolbar for long project filters on mobile", async ({ page }) => { + const draftID = "draft_workspace_mobile_filter" + const other = { + ...project, + id: "proj_workspaces_other", + name: "A second project with a deliberately long workspace filter label", + worktree: "C:/OpenCode/AnotherWorkspaceProject", + sandboxes: ["C:/OpenCode/worktree/another/feature-with-a-long-name"], + } + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/project**", async (route) => { + if (new URL(route.request().url()).pathname !== "/project") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify([project, other]), + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + await page.setViewportSize({ width: 375, height: 700 }) + + await page.goto(`/new-session?draftId=${draftID}`) + const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" }) + if (await dismissTabs.isVisible()) await dismissTabs.click() + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "View all" }).click() + const settings = page.locator(".settings-v2-dialog") + await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible() + expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) +}) + +test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", async ({ page }) => { + const draftID = "draft_workspace_delete_all" + const unknown = `${workspace}-unknown` + const clean = `${workspace}-clean` + const later = `${workspace}-later` + const inventory = { ...project, sandboxes: [workspace, unknown, clean] } + const removals: string[] = [] + let release = () => {} + const deleteGate = new Promise((resolve) => { + release = resolve + }) + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project: inventory, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/vcs/status**", async (route) => { + const directory = new URL(route.request().url()).searchParams.get("directory") + if (directory === workspace) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify([{ path: "dirty.ts", status: "modified" }]), + }) + return + } + if (directory === unknown) { + await route.fulfill({ + status: 500, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ message: "status failed" }), + }) + return + } + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "[]", + }) + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" }, + }) + return + } + if (route.request().method() !== "DELETE") return route.fallback() + const body = route.request().postDataJSON() as { directory: string } + removals.push(body.directory) + await deleteGate + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "true", + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await transport.waitForConnection() + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "View all" }).click() + const settings = page.locator(".settings-v2-dialog") + await settings.getByRole("button", { name: "More options" }).click() + await page.getByRole("menuitem", { name: "Delete all workspaces" }).click() + const confirmation = page.locator('[data-dialog-layer="1"]') + await expect(confirmation).toContainText("Delete all 3 workspaces?") + await transport.send({ + directory: "global", + payload: { + id: "evt_workspace_added_after_confirmation", + type: "project.updated", + properties: { ...inventory, sandboxes: [...inventory.sandboxes, later] }, + }, + }) + await confirmation.getByRole("button", { name: "Delete all workspaces" }).click() + + await expect.poll(() => removals).toEqual([clean]) + const deleteButtons = settings.getByRole("button", { name: /Delete workspace/ }) + await expect.poll(() => deleteButtons.count()).toBeGreaterThan(0) + expect(await deleteButtons.evaluateAll((buttons) => buttons.every((button) => button.hasAttribute("disabled")))).toBe( + true, + ) + release() + await expect(settings.getByText(workspace, { exact: true })).toBeVisible() + await expect(settings.getByText(unknown, { exact: true })).toBeVisible() + await expect(settings.getByText(clean, { exact: true })).toHaveCount(0) + await expect(settings.getByText(later, { exact: true })).toBeVisible() +}) + +test("applies the recovered new-workspace default without automatic cleanup", async ({ page }) => { + const draftID = "draft_workspace_default" + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ draftID, root, server }) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { newLayoutDesigns: true }, + workspaces: { defaultDestination: "new", lastUsed: {} }, + }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory: root }]), + ) + }, + { draftID, root, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() + await expect(page.getByText("from main", { exact: true })).toBeVisible() +}) + test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => { const draftID = "draft_workspace_submit" const sessionID = "ses_workspace_submit" diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index bb36eb9ea81..fb972ea3f50 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -12,10 +12,11 @@ export function PromptWorkspaceSelector(props: { branch?: string onChange: (value: string) => void onDone: () => void + onViewAll: () => void }) { const language = useLanguage() const [search, setSearch] = createSignal("") - let pending: { type: "select"; value: string } | undefined + let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined const selected = () => (props.value === props.projectRoot ? "main" : props.value) const workspaces = createMemo(() => { const query = search().trim().toLowerCase() @@ -37,6 +38,10 @@ export function PromptWorkspaceSelector(props: { const action = pending pending = undefined if (action?.type === "select") props.onChange(action.value) + if (action?.type === "viewAll") { + props.onViewAll() + return + } props.onDone() } const label = () => { @@ -119,19 +124,24 @@ export function PromptWorkspaceSelector(props: { + + (pending = { type: "viewAll" })}> + {language.t("common.viewAll")} + - + ) } -export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { +export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; connected?: boolean }) { const language = useLanguage() const label = () => { if (props.noGit) return language.t("session.new.git.none") if (!props.branch) return undefined + if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch }) return props.branch } @@ -139,15 +149,24 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { {(value) => ( <> - + + + -
- +
+ {value()}
diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 4116f4a6208..5b2478c04c1 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,6 +10,7 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { SettingsWorkspacesV2 } from "./workspaces" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" @@ -71,6 +72,10 @@ export const DialogSettings: Component<{
{language.t("settings.section.server")}
+ + + {language.t("settings.tab.workspaces")} + {language.t("status.popover.tab.servers")} @@ -99,6 +104,9 @@ export const DialogSettings: Component<{ + + + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 613ac96cbae..0109e02eea2 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -22,6 +22,7 @@ import { terminalDefault, terminalFontFamily, terminalInput, + type WorkspaceDefaultDestination, useSettings, } from "@/context/settings" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" @@ -189,6 +190,11 @@ export const SettingsGeneralV2: Component<{ { value: "light", label: language.t("theme.scheme.light") }, { value: "dark", label: language.t("theme.scheme.dark") }, ]) + const workspaceDestinationOptions = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [ + { value: "last-used", label: language.t("settings.workspaces.default.lastUsed") }, + { value: "local", label: language.t("settings.workspaces.default.local") }, + { value: "new", label: language.t("settings.workspaces.default.new") }, + ]) const languageOptions = createMemo(() => language.locales.map((locale) => ({ @@ -275,6 +281,24 @@ export const SettingsGeneralV2: Component<{ /> + + option.value === settings.workspaces.defaultDestination(), + )} + value={(option) => option.value} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)} + /> + + span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-v2-workspaces-session-active { + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-size: 11px; + line-height: 14px; +} + +.settings-v2-workspaces-empty { + display: flex; + align-items: center; + justify-content: center; + padding-block: 48px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +@media (max-width: 639px) { + .settings-v2-workspaces-header { + padding: 24px 20px 20px; + } + + .settings-v2-tab-body.settings-v2-workspaces { + padding: 0 20px 24px; + } + + .settings-v2-workspaces-toolbar, + .settings-v2-workspaces-main { + align-items: flex-start; + } + + .settings-v2-workspaces-toolbar { + flex-wrap: wrap; + } + + .settings-v2-workspaces-toolbar-actions { + width: 100%; + flex-wrap: wrap; + justify-content: space-between; + } + + .settings-v2-workspaces-inventory [data-component="settings-v2-list"] { + padding: 14px; + } + + .settings-v2-workspaces-path { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; + } + + .settings-v2-workspaces-active { + display: none; + } +} + [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { width: 480px; max-width: calc(100vw - 32px); diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx new file mode 100644 index 00000000000..0cb996cd480 --- /dev/null +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -0,0 +1,470 @@ +import type { Component } from "solid-js" +import { For, Show, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { Project, Session } from "@opencode-ai/sdk/v2/client" +import { useQuery } from "@tanstack/solid-query" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { getFilename } from "@opencode-ai/core/util/path" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { showToast } from "@/utils/toast" +import { getRelativeTime } from "@/utils/time" +import { pathKey } from "@/utils/path-key" +import { SettingsListV2 } from "./parts/list" +import { useTabs } from "@/context/tabs" +import { usePlatform } from "@/context/platform" +import { clearWorkspaceTerminals } from "@/context/terminal" +import { ServerConnection } from "@/context/server" +import { + containsDirectory, + filterWorkspaceInventory, + inspectWorkspaceDeletion, + mergeWorkspaceSessionInventory, + removeWorkspacesSequentially, + runWorkspaceDeleteTransaction, + sessionsForWorkspace, + type WorkspaceDeleteTransaction, + type WorkspaceDeleteInspection, + workspaceInventory, +} from "@/utils/workspace" +import { listAllSessions, normalizeSessionInfo } from "@/utils/session" +import type { ServerScope } from "@/utils/server-scope" +import "./settings-v2.css" + +type Workspace = { + directory: string + project: Project +} + +export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const tabs = useTabs() + const platform = usePlatform() + const [store, setStore] = createStore({ + project: "all", + deleting: undefined as string | undefined, + deletingAll: false, + transaction: undefined as WorkspaceDeleteTransaction, + }) + + const workspaces = createMemo(() => workspaceInventory(serverSync().data.project)) + const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length)) + const projectName = (project: Project) => project.name || getFilename(project.worktree) + const projectOptions = createMemo(() => [ + { id: "all", label: language.t("settings.workspaces.filter.all") }, + ...projects().map((project) => ({ id: project.id, label: projectName(project) })), + ]) + const selectedProject = createMemo(() => + store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all", + ) + const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) + const captureDeleteContext = () => { + const sdk = serverSDK() + return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } + } + const loadSessions = async (context = captureDeleteContext()) => { + const protocol = await context.sdk.protocol + const fetched = + protocol === "v1" + ? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => { + if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory") + return response.data.map(normalizeSessionInfo) + }) + : await listAllSessions(context.sdk.api.session, { order: "desc" }) + return mergeWorkspaceSessionInventory( + fetched, + Object.values(context.sync.session.data.info).filter((session): session is Session => !!session), + ) + } + const sessionQuery = useQuery(() => ({ + queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const, + queryFn: () => loadSessions(), + refetchOnMount: "always", + })) + const workspaceSessions = (workspace: Workspace) => sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) + const sessionCount = (workspace: Workspace) => { + if (sessionQuery.isPending) return language.t("session.messages.loading") + if (sessionQuery.isError) return language.t("common.requestFailed") + const count = workspaceSessions(workspace).length + return language.t(count === 1 ? "settings.workspaces.sessions.one" : "settings.workspaces.sessions.other", { + count, + project: projectName(workspace.project), + }) + } + const lastActive = (workspace: Workspace) => { + const updated = workspaceSessions(workspace)[0]?.time.updated + if (!updated) return undefined + return getRelativeTime(new Date(updated).toISOString(), language.t) + } + + const inspect = async (workspace: Workspace, context = captureDeleteContext()) => { + const [status, sessions] = await Promise.all([ + Promise.all([ + context.sdk.api.vcs.status({ location: { directory: workspace.directory } }), + context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }), + ]) + .then(([working, branch]) => + working.data.length > 0 || branch.data.length > 0 ? ("dirty" as const) : ("clean" as const), + ) + .catch(() => "unknown" as const), + loadSessions(context).catch(() => undefined), + ]) + const result = inspectWorkspaceDeletion({ + workspace: workspace.directory, + activeDirectory: context.activeDirectory, + sessions: sessions ?? [], + status: sessions ? status : "unknown", + }) + return { result, sessions: sessions ?? [] } + } + const inspectionMessage = (result: WorkspaceDeleteInspection) => { + if (result === "active") return language.t("settings.workspaces.delete.blocked.active") + if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked") + if (result === "dirty") return language.t("workspace.status.dirty") + if (result === "unknown") return language.t("workspace.status.error") + return language.t("workspace.status.clean") + } + const blocked = (result: WorkspaceDeleteInspection) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: inspectionMessage(result), + }) + } + + const remove = async (workspace: Workspace, bulk = false, context = captureDeleteContext()) => { + if (!bulk) setStore("deleting", pathKey(workspace.directory)) + const preflight = await inspect(workspace, context) + if (preflight.result !== "safe") { + blocked(preflight.result) + if (!bulk) setStore("deleting", undefined) + return + } + const removed = await context.sdk.client.worktree + .remove({ + directory: workspace.project.worktree, + worktreeRemoveInput: { directory: workspace.directory }, + }) + .then((result) => result.data) + .catch((error) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + return false + }) + if (!bulk) setStore("deleting", undefined) + if (!removed) return + tabs.store.forEach((tab) => { + if (tab.type !== "draft" || tab.server !== context.server) return + const directoryMatches = containsDirectory(workspace.directory, tab.directory) + const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree) + if (!directoryMatches && !worktreeMatches) return + tabs.updateDraft(tab.draftID, { + directory: directoryMatches ? workspace.project.worktree : tab.directory, + worktree: undefined, + }) + }) + clearWorkspaceTerminals( + workspace.directory, + preflight.sessions.map((session) => session.id), + platform, + context.sdk.scope, + ) + context.sync.set( + "project", + produce((draft) => { + const project = draft.find((item) => item.id === workspace.project.id) + if (!project) return + project.sandboxes = (project.sandboxes ?? []).filter( + (directory) => pathKey(directory) !== pathKey(workspace.directory), + ) + }), + ) + } + + let inspectionID = 0 + const releaseConfirmation = () => { + if (store.transaction === "confirm") setStore("transaction", undefined) + } + let transactionID = 0 + const transact = async (task: () => Promise) => { + await runWorkspaceDeleteTransaction({ + token: ++transactionID, + set: (update) => setStore("transaction", update), + task: async () => { + try { + await task() + } finally { + setStore({ deleting: undefined, deletingAll: false }) + } + }, + }) + } + const confirmDelete = (workspace: Workspace) => { + if (store.transaction) return + const context = captureDeleteContext() + const current = ++inspectionID + setStore("transaction", "confirm") + void dialog.push( + () => ( + inspect(workspace, context)} + inspectionMessage={inspectionMessage} + onDelete={() => transact(() => remove(workspace, false, context))} + /> + ), + releaseConfirmation, + ) + } + const removeAll = async (inventory: Workspace[], context: ReturnType) => { + setStore("deletingAll", true) + await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, true, context)) + } + const confirmDeleteAll = () => { + if (store.transaction) return + const context = captureDeleteContext() + const inventory = [...workspaces()] + const project = language.t("settings.workspaces.filter.all") + setStore("transaction", "confirm") + void dialog.push( + () => ( + transact(() => removeAll(inventory, context))} + /> + ), + releaseConfirmation, + ) + } + + return ( + <> +
+

{language.t("settings.tab.workspaces")}

+
+ +
+
+ + {language.t( + workspaces().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other", + { + count: workspaces().length, + }, + )} + +
+ 1}> + option.id === selectedProject())} + value={(option) => option.id} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && setStore("project", option.id)} + /> + + 0}> + + } + /> + + + + + {language.t("settings.workspaces.deleteAll")} + + + + + + +
+
+ +
+ 0} + fallback={
{language.t("settings.workspaces.empty")}
} + > + + + {(workspace) => { + const linked = () => workspaceSessions(workspace) + return ( +
+
+
+ + + {workspace.directory} + + + + {(value) => {value()}} + +
+ {sessionCount(workspace)} + 0}> +
+ + {(session, index) => ( + + {session.title} + + + {language.t("settings.workspaces.lastActiveSession")} + + + + )} + +
+
+
+ } + onClick={() => confirmDelete(workspace)} + /> +
+ ) + }} +
+
+
+
+
+ + ) +} + +function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise }) { + const dialog = useDialog() + const language = useLanguage() + const remove = () => { + const deleting = props.onDelete() + dialog.close() + void deleting + } + + return ( + + + + {language.t("settings.workspaces.deleteAll.confirm", { count: props.count })} +
+ {language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })} + + } + /> +
+ + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("settings.workspaces.deleteAll")} + + +
+ ) +} + +function DialogDeleteWorkspace(props: { + workspace: Workspace + scope: ServerScope + inspectionID: number + inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }> + inspectionMessage: (result: WorkspaceDeleteInspection) => string + onDelete: () => Promise +}) { + const dialog = useDialog() + const language = useLanguage() + const status = useQuery(() => ({ + queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const, + queryFn: props.inspect, + staleTime: 0, + })) + const description = () => { + if (status.isPending) return language.t("workspace.status.checking") + if (status.isError) return language.t("workspace.status.error") + return props.inspectionMessage(status.data?.result ?? "unknown") + } + const remove = () => { + const deleting = props.onDelete() + dialog.close() + void deleting + } + + return ( + + + + {language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })} +
+ {props.workspace.directory} +
+ {language.t("settings.workspaces.delete.warning")} +
+ {description()} + + } + /> +
+ + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("workspace.delete.button")} + + +
+ ) +} diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index cf484cdefa5..27c88c4d284 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -17,6 +17,7 @@ import { loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses, + shouldRefreshWorkspaceSessions, } from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" @@ -153,6 +154,42 @@ describe("session move normalization", () => { session.apply(event) expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" }) }) + + test("refreshes workspace inventory for current and V1 lifecycle events", () => { + const current = adaptServerEvent({ + id: "event-current-move-refresh", + created: 10, + type: "session.moved", + data: { sessionID: "session", location: { directory: "/destination" } }, + } as OpenCodeEvent) + const legacy = { + type: "session.next.moved", + properties: { timestamp: 10, sessionID: "session", location: { directory: "/destination" } }, + } as Event + + expect(shouldRefreshWorkspaceSessions(current)).toBe(true) + expect( + shouldRefreshWorkspaceSessions( + adaptServerEvent({ + id: "event-current-rename-refresh", + created: 10, + type: "session.renamed", + data: { sessionID: "session", title: "Renamed" }, + } as OpenCodeEvent), + ), + ).toBe(true) + expect(shouldRefreshWorkspaceSessions(legacy)).toBe(true) + expect( + shouldRefreshWorkspaceSessions({ type: "session.created", properties: { info: sessionAt("/source") } } as Event), + ).toBe(true) + expect( + shouldRefreshWorkspaceSessions({ type: "session.updated", properties: { info: sessionAt("/source") } } as Event), + ).toBe(true) + expect( + shouldRefreshWorkspaceSessions({ type: "session.deleted", properties: { info: sessionAt("/source") } } as Event), + ).toBe(true) + expect(shouldRefreshWorkspaceSessions({ type: "server.connected", properties: {} } as Event)).toBe(false) + }) }) describe("pickDirectoriesToEvict", () => { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b96b9480a47..f87dcc52f35 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -57,6 +57,21 @@ export function captureSessionMove(event: ServerEvent, get: (sessionID: string) if (!sessionID) return return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const } } + +export function shouldRefreshWorkspaceSessions(event: ServerEvent) { + const type: string = event.type + const current: string | undefined = event.current?.type + return ( + type === "session.created" || + type === "session.updated" || + type === "session.deleted" || + type === "session.next.moved" || + current === "session.moved" || + current === "session.renamed" || + current === "session.archived" || + current === "session.forked" + ) +} import { persisted } from "@/utils/persist" import type { ServerApi } from "@/utils/server" import type { @@ -574,6 +589,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (event.current) session.applyV2(event.current) session.apply(event) if (moved) reindexSession(moved.sessionID, moved.from) + if (shouldRefreshWorkspaceSessions(event)) { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions", + }) + } if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fe8b4e3c03f..a7ae651c844 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -3,6 +3,10 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +export type WorkspaceDefaultDestination = "last-used" | "local" | "new" +export type WorkspaceLastUsed = "local" | "workspace" export interface NotificationSettings { agent: boolean @@ -49,6 +53,10 @@ export interface Settings { permissions: { autoApprove: boolean } + workspaces: { + defaultDestination: WorkspaceDefaultDestination + lastUsed: Record + } notifications: NotificationSettings sounds: SoundSettings } @@ -200,6 +208,10 @@ const defaultSettings: Settings = { permissions: { autoApprove: false, }, + workspaces: { + defaultDestination: "last-used", + lastUsed: {}, + }, notifications: { agent: true, permissions: true, @@ -483,6 +495,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("permissions", "autoApprove", value) }, }, + workspaces: { + defaultDestination: withFallback( + () => store.workspaces?.defaultDestination, + defaultSettings.workspaces.defaultDestination, + ), + setDefaultDestination(value: WorkspaceDefaultDestination) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + defaultDestination: value, + })) + }, + lastUsed(scope: ServerScope, projectID: string) { + return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)] + }, + setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value }, + })) + }, + }, notifications: { agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), setAgent(value: boolean) { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 15abb946e95..d920818aea4 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -894,6 +894,31 @@ export const dict = { "session.delete.confirm": 'حذف الجلسة "{{name}}"؟', "session.delete.button": "حذف الجلسة", "workspace.new": "مساحة عمل جديدة", + "common.viewAll": "عرض الكل", + "session.new.workspace.fromBranch": "من {{branch}}", + "settings.tab.workspaces": "مساحات العمل", + "settings.workspaces.filter.all": "جميع المشاريع", + "settings.workspaces.empty": "لا توجد مساحات عمل", + "settings.workspaces.count.one": "{{count}} مساحة عمل", + "settings.workspaces.count.other": "{{count}} مساحات عمل", + "settings.workspaces.sessions.one": "{{count}} جلسة في {{project}}", + "settings.workspaces.sessions.other": "{{count}} جلسات في {{project}}", + "settings.workspaces.lastActive": "آخر نشاط: {{time}}", + "settings.workspaces.lastActiveSession": "آخر جلسة نشطة", + "settings.workspaces.deleteAll": "حذف جميع مساحات العمل", + "settings.workspaces.deleteAll.confirm": "حذف جميع مساحات العمل البالغ عددها {{count}}؟", + "settings.workspaces.delete.warning": + "سيتم حذف دليل مساحة العمل والفرع نهائيًا. لن تتم المتابعة إلا إذا كانت نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.", + "settings.workspaces.deleteAll.warning": + "سيتم حذف مساحات العمل المحددة وعددها {{count}} في {{project}} نهائيًا فقط إذا كانت كل واحدة نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.", + "settings.workspaces.delete.blocked.active": "لا يمكن حذف مساحة العمل النشطة.", + "settings.workspaces.delete.blocked.linked": "تحتوي مساحة العمل هذه على جلسات مرتبطة ولا يمكن حذفها.", + "settings.workspaces.section.defaults": "الجلسات الجديدة", + "settings.workspaces.default.title": "البيئة الافتراضية", + "settings.workspaces.default.description": "اختر مكان بدء الجلسات الجديدة", + "settings.workspaces.default.lastUsed": "آخر بيئة مستخدمة لكل مشروع", + "settings.workspaces.default.local": "دليل محلي", + "settings.workspaces.default.new": "مساحة عمل جديدة", "workspace.move.title": "نقل إلى مساحة العمل", "workspace.move.failed": "تعذّر نقل الجلسة", "workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 0d496aea2c9..8e37f9186a1 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -908,6 +908,32 @@ export const dict = { "session.delete.confirm": 'Excluir sessão "{{name}}"?', "session.delete.button": "Excluir sessão", "workspace.new": "Novo espaço de trabalho", + "common.viewAll": "Ver todos", + "session.new.workspace.fromBranch": "de {{branch}}", + "settings.tab.workspaces": "Espaços de trabalho", + "settings.workspaces.filter.all": "Todos os projetos", + "settings.workspaces.empty": "Nenhum espaço de trabalho", + "settings.workspaces.count.one": "{{count}} espaço de trabalho", + "settings.workspaces.count.other": "{{count}} espaços de trabalho", + "settings.workspaces.sessions.one": "{{count}} sessão em {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessões em {{project}}", + "settings.workspaces.lastActive": "Última atividade: {{time}}", + "settings.workspaces.lastActiveSession": "Última sessão ativa", + "settings.workspaces.deleteAll": "Excluir todos os espaços de trabalho", + "settings.workspaces.deleteAll.confirm": "Excluir todos os {{count}} espaços de trabalho?", + "settings.workspaces.delete.warning": + "O diretório e a ramificação do espaço de trabalho serão removidos permanentemente. A exclusão só prossegue se ele estiver limpo, inativo e sem sessões vinculadas.", + "settings.workspaces.deleteAll.warning": + "Os {{count}} espaços de trabalho selecionados em {{project}} serão removidos permanentemente somente se cada um estiver limpo, inativo e sem sessões vinculadas.", + "settings.workspaces.delete.blocked.active": "O espaço de trabalho ativo não pode ser excluído.", + "settings.workspaces.delete.blocked.linked": + "Este espaço de trabalho tem sessões vinculadas e não pode ser excluído.", + "settings.workspaces.section.defaults": "Novas sessões", + "settings.workspaces.default.title": "Ambiente padrão", + "settings.workspaces.default.description": "Escolha onde as novas sessões começam", + "settings.workspaces.default.lastUsed": "Último usado por projeto", + "settings.workspaces.default.local": "Diretório local", + "settings.workspaces.default.new": "Novo espaço de trabalho", "workspace.move.title": "Mover para o espaço de trabalho", "workspace.move.failed": "Não foi possível mover a sessão", "workspace.lifecycle.creating": "Criando espaço de trabalho", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 22c9ee7cedb..052394e2a79 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -984,6 +984,31 @@ export const dict = { "session.delete.button": "Izbriši sesiju", "workspace.new": "Novi radni prostor", + "common.viewAll": "Prikaži sve", + "session.new.workspace.fromBranch": "iz {{branch}}", + "settings.tab.workspaces": "Radni prostori", + "settings.workspaces.filter.all": "Svi projekti", + "settings.workspaces.empty": "Nema radnih prostora", + "settings.workspaces.count.one": "{{count}} radni prostor", + "settings.workspaces.count.other": "{{count}} radnih prostora", + "settings.workspaces.sessions.one": "{{count}} sesija u projektu {{project}}", + "settings.workspaces.sessions.other": "{{count}} sesija u projektu {{project}}", + "settings.workspaces.lastActive": "Posljednja aktivnost: {{time}}", + "settings.workspaces.lastActiveSession": "Posljednja aktivna sesija", + "settings.workspaces.deleteAll": "Izbriši sve radne prostore", + "settings.workspaces.deleteAll.confirm": "Izbrisati svih {{count}} radnih prostora?", + "settings.workspaces.delete.warning": + "Direktorij radnog prostora i grana bit će trajno izbrisani. Brisanje se nastavlja samo ako je radni prostor čist, neaktivan i nema povezanih sesija.", + "settings.workspaces.deleteAll.warning": + "Odabranih {{count}} radnih prostora u {{project}} bit će trajno izbrisano samo ako je svaki čist, neaktivan i nema povezanih sesija.", + "settings.workspaces.delete.blocked.active": "Aktivni radni prostor nije moguće izbrisati.", + "settings.workspaces.delete.blocked.linked": "Ovaj radni prostor ima povezane sesije i nije ga moguće izbrisati.", + "settings.workspaces.section.defaults": "Nove sesije", + "settings.workspaces.default.title": "Zadano okruženje", + "settings.workspaces.default.description": "Odaberite gdje počinju nove sesije", + "settings.workspaces.default.lastUsed": "Posljednje korišteno po projektu", + "settings.workspaces.default.local": "Lokalni direktorij", + "settings.workspaces.default.new": "Novi radni prostor", "workspace.move.title": "Premjesti u radni prostor", "workspace.move.failed": "Premještanje sesije nije uspjelo", "workspace.lifecycle.creating": "Kreiranje radnog prostora", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 4fd86c1c9b8..18ec19f7dcc 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -976,6 +976,31 @@ export const dict = { "session.delete.button": "Slet session", "workspace.new": "Nyt arbejdsområde", + "common.viewAll": "Vis alle", + "session.new.workspace.fromBranch": "fra {{branch}}", + "settings.tab.workspaces": "Arbejdsområder", + "settings.workspaces.filter.all": "Alle projekter", + "settings.workspaces.empty": "Ingen arbejdsområder", + "settings.workspaces.count.one": "{{count}} arbejdsområde", + "settings.workspaces.count.other": "{{count}} arbejdsområder", + "settings.workspaces.sessions.one": "{{count}} session i {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessioner i {{project}}", + "settings.workspaces.lastActive": "Senest aktiv: {{time}}", + "settings.workspaces.lastActiveSession": "Senest aktive session", + "settings.workspaces.deleteAll": "Slet alle arbejdsområder", + "settings.workspaces.deleteAll.confirm": "Slet alle {{count}} arbejdsområder?", + "settings.workspaces.delete.warning": + "Arbejdsområdets mappe og gren fjernes permanent. Sletning fortsætter kun, hvis det er rent, inaktivt og ikke har tilknyttede sessioner.", + "settings.workspaces.deleteAll.warning": + "De {{count}} valgte arbejdsområder i {{project}} fjernes permanent, men kun hvis hvert område er rent, inaktivt og ikke har tilknyttede sessioner.", + "settings.workspaces.delete.blocked.active": "Det aktive arbejdsområde kan ikke slettes.", + "settings.workspaces.delete.blocked.linked": "Dette arbejdsområde har tilknyttede sessioner og kan ikke slettes.", + "settings.workspaces.section.defaults": "Nye sessioner", + "settings.workspaces.default.title": "Standardmiljø", + "settings.workspaces.default.description": "Vælg, hvor nye sessioner starter", + "settings.workspaces.default.lastUsed": "Senest brugt pr. projekt", + "settings.workspaces.default.local": "Lokal mappe", + "settings.workspaces.default.new": "Nyt arbejdsområde", "workspace.move.title": "Flyt til arbejdsområde", "workspace.move.failed": "Sessionen kunne ikke flyttes", "workspace.lifecycle.creating": "Opretter arbejdsområde", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 56e743281a1..43bc0f34a62 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -920,6 +920,32 @@ export const dict = { "session.delete.confirm": 'Sitzung "{{name}}" löschen?', "session.delete.button": "Sitzung löschen", "workspace.new": "Neuer Arbeitsbereich", + "common.viewAll": "Alle anzeigen", + "session.new.workspace.fromBranch": "aus {{branch}}", + "settings.tab.workspaces": "Arbeitsbereiche", + "settings.workspaces.filter.all": "Alle Projekte", + "settings.workspaces.empty": "Keine Arbeitsbereiche", + "settings.workspaces.count.one": "{{count}} Arbeitsbereich", + "settings.workspaces.count.other": "{{count}} Arbeitsbereiche", + "settings.workspaces.sessions.one": "{{count}} Sitzung in {{project}}", + "settings.workspaces.sessions.other": "{{count}} Sitzungen in {{project}}", + "settings.workspaces.lastActive": "Zuletzt aktiv: {{time}}", + "settings.workspaces.lastActiveSession": "Zuletzt aktive Sitzung", + "settings.workspaces.deleteAll": "Alle Arbeitsbereiche löschen", + "settings.workspaces.deleteAll.confirm": "Alle {{count}} Arbeitsbereiche löschen?", + "settings.workspaces.delete.warning": + "Verzeichnis und Branch des Arbeitsbereichs werden dauerhaft entfernt. Die Löschung erfolgt nur, wenn er sauber und inaktiv ist und keine verknüpften Sitzungen hat.", + "settings.workspaces.deleteAll.warning": + "Die {{count}} ausgewählten Arbeitsbereiche in {{project}} werden nur dauerhaft entfernt, wenn jeder sauber und inaktiv ist und keine verknüpften Sitzungen hat.", + "settings.workspaces.delete.blocked.active": "Der aktive Arbeitsbereich kann nicht gelöscht werden.", + "settings.workspaces.delete.blocked.linked": + "Dieser Arbeitsbereich hat verknüpfte Sitzungen und kann nicht gelöscht werden.", + "settings.workspaces.section.defaults": "Neue Sitzungen", + "settings.workspaces.default.title": "Standardumgebung", + "settings.workspaces.default.description": "Wählen Sie, wo neue Sitzungen starten", + "settings.workspaces.default.lastUsed": "Zuletzt pro Projekt verwendet", + "settings.workspaces.default.local": "Lokales Verzeichnis", + "settings.workspaces.default.new": "Neuer Arbeitsbereich", "workspace.move.title": "In Arbeitsbereich verschieben", "workspace.move.failed": "Sitzung konnte nicht verschoben werden", "workspace.lifecycle.creating": "Arbeitsbereich wird erstellt", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1e12f3b2bbd..21fd2a95aad 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1075,6 +1075,31 @@ export const dict = { "session.delete.button": "Delete session", "workspace.new": "New workspace", + "common.viewAll": "View all", + "session.new.workspace.fromBranch": "from {{branch}}", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActive": "Last active {{time}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": + "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": + "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.section.defaults": "New sessions", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", "workspace.move.title": "Move to workspace", "workspace.move.failed": "Failed to move session", "workspace.lifecycle.creating": "Creating workspace", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index bb7474408ef..5d671e5f37c 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -992,6 +992,32 @@ export const dict = { "session.delete.button": "Eliminar sesión", "workspace.new": "Nuevo espacio de trabajo", + "common.viewAll": "Ver todo", + "session.new.workspace.fromBranch": "desde {{branch}}", + "settings.tab.workspaces": "Espacios de trabajo", + "settings.workspaces.filter.all": "Todos los proyectos", + "settings.workspaces.empty": "No hay espacios de trabajo", + "settings.workspaces.count.one": "{{count}} espacio de trabajo", + "settings.workspaces.count.other": "{{count}} espacios de trabajo", + "settings.workspaces.sessions.one": "{{count}} sesión en {{project}}", + "settings.workspaces.sessions.other": "{{count}} sesiones en {{project}}", + "settings.workspaces.lastActive": "Última actividad: {{time}}", + "settings.workspaces.lastActiveSession": "Última sesión activa", + "settings.workspaces.deleteAll": "Eliminar todos los espacios de trabajo", + "settings.workspaces.deleteAll.confirm": "¿Eliminar los {{count}} espacios de trabajo?", + "settings.workspaces.delete.warning": + "El directorio y la rama del espacio de trabajo se eliminarán permanentemente. La eliminación solo continuará si está limpio, inactivo y no tiene sesiones vinculadas.", + "settings.workspaces.deleteAll.warning": + "Los {{count}} espacios de trabajo seleccionados en {{project}} se eliminarán permanentemente solo si cada uno está limpio, inactivo y no tiene sesiones vinculadas.", + "settings.workspaces.delete.blocked.active": "El espacio de trabajo activo no se puede eliminar.", + "settings.workspaces.delete.blocked.linked": + "Este espacio de trabajo tiene sesiones vinculadas y no se puede eliminar.", + "settings.workspaces.section.defaults": "Nuevas sesiones", + "settings.workspaces.default.title": "Entorno predeterminado", + "settings.workspaces.default.description": "Elige dónde se inician las nuevas sesiones", + "settings.workspaces.default.lastUsed": "Último usado en cada proyecto", + "settings.workspaces.default.local": "Directorio local", + "settings.workspaces.default.new": "Nuevo espacio de trabajo", "workspace.move.title": "Mover al espacio de trabajo", "workspace.move.failed": "No se pudo mover la sesión", "workspace.lifecycle.creating": "Creando espacio de trabajo", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 470b6fcd283..d170a988d08 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -919,6 +919,32 @@ export const dict = { "session.delete.confirm": 'Supprimer la session "{{name}}" ?', "session.delete.button": "Supprimer la session", "workspace.new": "Nouvel espace de travail", + "common.viewAll": "Tout afficher", + "session.new.workspace.fromBranch": "depuis {{branch}}", + "settings.tab.workspaces": "Espaces de travail", + "settings.workspaces.filter.all": "Tous les projets", + "settings.workspaces.empty": "Aucun espace de travail", + "settings.workspaces.count.one": "{{count}} espace de travail", + "settings.workspaces.count.other": "{{count}} espaces de travail", + "settings.workspaces.sessions.one": "{{count}} session dans {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions dans {{project}}", + "settings.workspaces.lastActive": "Dernière activité : {{time}}", + "settings.workspaces.lastActiveSession": "Dernière session active", + "settings.workspaces.deleteAll": "Supprimer tous les espaces de travail", + "settings.workspaces.deleteAll.confirm": "Supprimer les {{count}} espaces de travail ?", + "settings.workspaces.delete.warning": + "Le dossier et la branche de l’espace de travail seront supprimés définitivement. La suppression ne continue que s’il est propre, inactif et sans session liée.", + "settings.workspaces.deleteAll.warning": + "Les {{count}} espaces de travail sélectionnés dans {{project}} seront supprimés définitivement uniquement si chacun est propre, inactif et sans session liée.", + "settings.workspaces.delete.blocked.active": "L’espace de travail actif ne peut pas être supprimé.", + "settings.workspaces.delete.blocked.linked": + "Cet espace de travail contient des sessions liées et ne peut pas être supprimé.", + "settings.workspaces.section.defaults": "Nouvelles sessions", + "settings.workspaces.default.title": "Environnement par défaut", + "settings.workspaces.default.description": "Choisissez où démarrent les nouvelles sessions", + "settings.workspaces.default.lastUsed": "Dernier utilisé par projet", + "settings.workspaces.default.local": "Dossier local", + "settings.workspaces.default.new": "Nouvel espace de travail", "workspace.move.title": "Déplacer vers l’espace de travail", "workspace.move.failed": "Impossible de déplacer la session", "workspace.lifecycle.creating": "Création de l’espace de travail", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index f38c10ef036..16520a5b471 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -901,6 +901,31 @@ export const dict = { "session.delete.confirm": 'セッション "{{name}}" を削除しますか?', "session.delete.button": "セッションを削除", "workspace.new": "新しいワークスペース", + "common.viewAll": "すべて表示", + "session.new.workspace.fromBranch": "{{branch}} から", + "settings.tab.workspaces": "ワークスペース", + "settings.workspaces.filter.all": "すべてのプロジェクト", + "settings.workspaces.empty": "ワークスペースなし", + "settings.workspaces.count.one": "{{count}}件のワークスペース", + "settings.workspaces.count.other": "{{count}}件のワークスペース", + "settings.workspaces.sessions.one": "{{project}}のセッション{{count}}件", + "settings.workspaces.sessions.other": "{{project}}のセッション{{count}}件", + "settings.workspaces.lastActive": "最終アクティブ: {{time}}", + "settings.workspaces.lastActiveSession": "最後にアクティブだったセッション", + "settings.workspaces.deleteAll": "すべてのワークスペースを削除", + "settings.workspaces.deleteAll.confirm": "{{count}}件のワークスペースをすべて削除しますか?", + "settings.workspaces.delete.warning": + "ワークスペースのディレクトリとブランチは完全に削除されます。クリーンで非アクティブかつリンクされたセッションがない場合にのみ削除されます。", + "settings.workspaces.deleteAll.warning": + "{{project}} で選択された{{count}}件のワークスペースは、それぞれがクリーンで非アクティブかつリンクされたセッションがない場合にのみ完全に削除されます。", + "settings.workspaces.delete.blocked.active": "アクティブなワークスペースは削除できません。", + "settings.workspaces.delete.blocked.linked": "このワークスペースにはリンクされたセッションがあるため削除できません。", + "settings.workspaces.section.defaults": "新しいセッション", + "settings.workspaces.default.title": "デフォルト環境", + "settings.workspaces.default.description": "新しいセッションの開始場所を選択します", + "settings.workspaces.default.lastUsed": "プロジェクトごとに前回使用した環境", + "settings.workspaces.default.local": "ローカルディレクトリ", + "settings.workspaces.default.new": "新しいワークスペース", "workspace.move.title": "ワークスペースに移動", "workspace.move.failed": "セッションを移動できませんでした", "workspace.lifecycle.creating": "ワークスペースを作成中", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index a28915b77cb..1bc7b34ae7d 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -742,6 +742,31 @@ export const dict = { "session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?', "session.delete.button": "세션 삭제", "workspace.new": "새 작업 공간", + "common.viewAll": "모두 보기", + "session.new.workspace.fromBranch": "{{branch}}에서", + "settings.tab.workspaces": "작업 공간", + "settings.workspaces.filter.all": "모든 프로젝트", + "settings.workspaces.empty": "작업 공간 없음", + "settings.workspaces.count.one": "작업 공간 {{count}}개", + "settings.workspaces.count.other": "작업 공간 {{count}}개", + "settings.workspaces.sessions.one": "{{project}}에 세션 {{count}}개", + "settings.workspaces.sessions.other": "{{project}}에 세션 {{count}}개", + "settings.workspaces.lastActive": "마지막 활동: {{time}}", + "settings.workspaces.lastActiveSession": "마지막 활성 세션", + "settings.workspaces.deleteAll": "모든 작업 공간 삭제", + "settings.workspaces.deleteAll.confirm": "작업 공간 {{count}}개를 모두 삭제할까요?", + "settings.workspaces.delete.warning": + "작업 공간 디렉터리와 브랜치가 영구적으로 삭제됩니다. 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 삭제됩니다.", + "settings.workspaces.deleteAll.warning": + "{{project}}에서 선택한 작업 공간 {{count}}개는 각각 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 영구적으로 삭제됩니다.", + "settings.workspaces.delete.blocked.active": "활성 작업 공간은 삭제할 수 없습니다.", + "settings.workspaces.delete.blocked.linked": "이 작업 공간에는 연결된 세션이 있어 삭제할 수 없습니다.", + "settings.workspaces.section.defaults": "새 세션", + "settings.workspaces.default.title": "기본 환경", + "settings.workspaces.default.description": "새 세션을 시작할 위치를 선택하세요", + "settings.workspaces.default.lastUsed": "프로젝트별 마지막 사용 환경", + "settings.workspaces.default.local": "로컬 디렉터리", + "settings.workspaces.default.new": "새 작업 공간", "workspace.move.title": "작업 공간으로 이동", "workspace.move.failed": "세션을 이동하지 못했습니다", "workspace.lifecycle.creating": "작업 공간 생성 중", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 6df07e139f7..c7a17d122e7 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -829,6 +829,31 @@ export const dict = { "session.delete.button": "Slett sesjon", "workspace.new": "Nytt arbeidsområde", + "common.viewAll": "Vis alle", + "session.new.workspace.fromBranch": "fra {{branch}}", + "settings.tab.workspaces": "Arbeidsområder", + "settings.workspaces.filter.all": "Alle prosjekter", + "settings.workspaces.empty": "Ingen arbeidsområder", + "settings.workspaces.count.one": "{{count}} arbeidsområde", + "settings.workspaces.count.other": "{{count}} arbeidsområder", + "settings.workspaces.sessions.one": "{{count}} økt i {{project}}", + "settings.workspaces.sessions.other": "{{count}} økter i {{project}}", + "settings.workspaces.lastActive": "Sist aktiv: {{time}}", + "settings.workspaces.lastActiveSession": "Sist aktive økt", + "settings.workspaces.deleteAll": "Slett alle arbeidsområder", + "settings.workspaces.deleteAll.confirm": "Slette alle {{count}} arbeidsområder?", + "settings.workspaces.delete.warning": + "Arbeidsområdets mappe og gren fjernes permanent. Sletting fortsetter bare hvis det er rent, inaktivt og ikke har tilknyttede økter.", + "settings.workspaces.deleteAll.warning": + "De {{count}} valgte arbeidsområdene i {{project}} fjernes permanent bare hvis hvert område er rent, inaktivt og ikke har tilknyttede økter.", + "settings.workspaces.delete.blocked.active": "Det aktive arbeidsområdet kan ikke slettes.", + "settings.workspaces.delete.blocked.linked": "Dette arbeidsområdet har tilknyttede økter og kan ikke slettes.", + "settings.workspaces.section.defaults": "Nye økter", + "settings.workspaces.default.title": "Standardmiljø", + "settings.workspaces.default.description": "Velg hvor nye økter starter", + "settings.workspaces.default.lastUsed": "Sist brukt per prosjekt", + "settings.workspaces.default.local": "Lokal mappe", + "settings.workspaces.default.new": "Nytt arbeidsområde", "workspace.move.title": "Flytt til arbeidsområde", "workspace.move.failed": "Kunne ikke flytte økten", "workspace.lifecycle.creating": "Oppretter arbeidsområde", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 15a65c722ba..592e1e7fb17 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -907,6 +907,31 @@ export const dict = { "session.delete.confirm": 'Usunąć sesję "{{name}}"?', "session.delete.button": "Usuń sesję", "workspace.new": "Nowa przestrzeń robocza", + "common.viewAll": "Pokaż wszystko", + "session.new.workspace.fromBranch": "z {{branch}}", + "settings.tab.workspaces": "Obszary robocze", + "settings.workspaces.filter.all": "Wszystkie projekty", + "settings.workspaces.empty": "Brak obszarów roboczych", + "settings.workspaces.count.one": "{{count}} obszar roboczy", + "settings.workspaces.count.other": "{{count}} obszarów roboczych", + "settings.workspaces.sessions.one": "{{count}} sesja w {{project}}", + "settings.workspaces.sessions.other": "{{count}} sesji w {{project}}", + "settings.workspaces.lastActive": "Ostatnia aktywność: {{time}}", + "settings.workspaces.lastActiveSession": "Ostatnia aktywna sesja", + "settings.workspaces.deleteAll": "Usuń wszystkie obszary robocze", + "settings.workspaces.deleteAll.confirm": "Usunąć wszystkie obszary robocze ({{count}})?", + "settings.workspaces.delete.warning": + "Katalog i gałąź obszaru roboczego zostaną trwale usunięte. Usuwanie nastąpi tylko wtedy, gdy obszar jest czysty, nieaktywny i nie ma powiązanych sesji.", + "settings.workspaces.deleteAll.warning": + "Wybrane obszary robocze ({{count}}) w {{project}} zostaną trwale usunięte tylko wtedy, gdy każdy jest czysty, nieaktywny i nie ma powiązanych sesji.", + "settings.workspaces.delete.blocked.active": "Nie można usunąć aktywnego obszaru roboczego.", + "settings.workspaces.delete.blocked.linked": "Ten obszar roboczy ma powiązane sesje i nie można go usunąć.", + "settings.workspaces.section.defaults": "Nowe sesje", + "settings.workspaces.default.title": "Domyślne środowisko", + "settings.workspaces.default.description": "Wybierz, gdzie mają się rozpoczynać nowe sesje", + "settings.workspaces.default.lastUsed": "Ostatnio używane w każdym projekcie", + "settings.workspaces.default.local": "Katalog lokalny", + "settings.workspaces.default.new": "Nowy obszar roboczy", "workspace.move.title": "Przenieś do obszaru roboczego", "workspace.move.failed": "Nie udało się przenieść sesji", "workspace.lifecycle.creating": "Tworzenie obszaru roboczego", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e7a6996f459..dda4b4adf40 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -987,6 +987,32 @@ export const dict = { "session.delete.button": "Удалить сессию", "workspace.new": "Новое рабочее пространство", + "common.viewAll": "Показать все", + "session.new.workspace.fromBranch": "из {{branch}}", + "settings.tab.workspaces": "Рабочие пространства", + "settings.workspaces.filter.all": "Все проекты", + "settings.workspaces.empty": "Нет рабочих пространств", + "settings.workspaces.count.one": "{{count}} рабочее пространство", + "settings.workspaces.count.other": "{{count}} рабочих пространств", + "settings.workspaces.sessions.one": "{{count}} сессия в {{project}}", + "settings.workspaces.sessions.other": "{{count}} сессий в {{project}}", + "settings.workspaces.lastActive": "Последняя активность: {{time}}", + "settings.workspaces.lastActiveSession": "Последний активный сеанс", + "settings.workspaces.deleteAll": "Удалить все рабочие пространства", + "settings.workspaces.deleteAll.confirm": "Удалить все рабочие пространства ({{count}})?", + "settings.workspaces.delete.warning": + "Каталог и ветка рабочего пространства будут удалены безвозвратно. Удаление выполняется только для чистого, неактивного пространства без связанных сеансов.", + "settings.workspaces.deleteAll.warning": + "Выбранные рабочие пространства ({{count}}) в {{project}} будут удалены безвозвратно, только если каждое из них чистое, неактивное и не имеет связанных сеансов.", + "settings.workspaces.delete.blocked.active": "Активное рабочее пространство нельзя удалить.", + "settings.workspaces.delete.blocked.linked": + "У этого рабочего пространства есть связанные сеансы, поэтому его нельзя удалить.", + "settings.workspaces.section.defaults": "Новые сеансы", + "settings.workspaces.default.title": "Среда по умолчанию", + "settings.workspaces.default.description": "Выберите, где будут запускаться новые сеансы", + "settings.workspaces.default.lastUsed": "Последняя использованная среда проекта", + "settings.workspaces.default.local": "Локальный каталог", + "settings.workspaces.default.new": "Новое рабочее пространство", "workspace.move.title": "Переместить в рабочую область", "workspace.move.failed": "Не удалось переместить сеанс", "workspace.lifecycle.creating": "Создание рабочей области", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index c41422d6487..cde55f45635 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -971,6 +971,31 @@ export const dict = { "session.delete.button": "ลบเซสชัน", "workspace.new": "พื้นที่ทำงานใหม่", + "common.viewAll": "ดูทั้งหมด", + "session.new.workspace.fromBranch": "จาก {{branch}}", + "settings.tab.workspaces": "พื้นที่ทำงาน", + "settings.workspaces.filter.all": "โปรเจกต์ทั้งหมด", + "settings.workspaces.empty": "ไม่มีพื้นที่ทำงาน", + "settings.workspaces.count.one": "{{count}} พื้นที่ทำงาน", + "settings.workspaces.count.other": "{{count}} พื้นที่ทำงาน", + "settings.workspaces.sessions.one": "{{count}} เซสชันใน {{project}}", + "settings.workspaces.sessions.other": "{{count}} เซสชันใน {{project}}", + "settings.workspaces.lastActive": "ใช้งานล่าสุด: {{time}}", + "settings.workspaces.lastActiveSession": "เซสชันที่ใช้งานล่าสุด", + "settings.workspaces.deleteAll": "ลบพื้นที่ทำงานทั้งหมด", + "settings.workspaces.deleteAll.confirm": "ลบพื้นที่ทำงานทั้งหมด {{count}} รายการหรือไม่", + "settings.workspaces.delete.warning": + "ไดเรกทอรีและสาขาของพื้นที่ทำงานจะถูกลบอย่างถาวร การลบจะดำเนินการต่อเมื่อพื้นที่ทำงานสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น", + "settings.workspaces.deleteAll.warning": + "พื้นที่ทำงานที่เลือก {{count}} รายการใน {{project}} จะถูกลบอย่างถาวรเมื่อแต่ละรายการสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น", + "settings.workspaces.delete.blocked.active": "ไม่สามารถลบพื้นที่ทำงานที่กำลังใช้งานอยู่ได้", + "settings.workspaces.delete.blocked.linked": "พื้นที่ทำงานนี้มีเซสชันที่เชื่อมโยงอยู่และไม่สามารถลบได้", + "settings.workspaces.section.defaults": "เซสชันใหม่", + "settings.workspaces.default.title": "สภาพแวดล้อมเริ่มต้น", + "settings.workspaces.default.description": "เลือกตำแหน่งเริ่มต้นของเซสชันใหม่", + "settings.workspaces.default.lastUsed": "ใช้ล่าสุดในแต่ละโปรเจกต์", + "settings.workspaces.default.local": "ไดเรกทอรีในเครื่อง", + "settings.workspaces.default.new": "เวิร์กสเปซใหม่", "workspace.move.title": "ย้ายไปยังพื้นที่ทำงาน", "workspace.move.failed": "ย้ายเซสชันไม่สำเร็จ", "workspace.lifecycle.creating": "กำลังสร้างพื้นที่ทำงาน", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index d505ec89d8a..679d786967e 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -991,6 +991,31 @@ export const dict = { "session.delete.button": "Oturumu sil", "workspace.new": "Yeni çalışma alanı", + "common.viewAll": "Tümünü göster", + "session.new.workspace.fromBranch": "{{branch}} dalından", + "settings.tab.workspaces": "Çalışma alanları", + "settings.workspaces.filter.all": "Tüm projeler", + "settings.workspaces.empty": "Çalışma alanı yok", + "settings.workspaces.count.one": "{{count}} çalışma alanı", + "settings.workspaces.count.other": "{{count}} çalışma alanı", + "settings.workspaces.sessions.one": "{{project}} projesinde {{count}} oturum", + "settings.workspaces.sessions.other": "{{project}} projesinde {{count}} oturum", + "settings.workspaces.lastActive": "Son etkinlik: {{time}}", + "settings.workspaces.lastActiveSession": "Son etkin oturum", + "settings.workspaces.deleteAll": "Tüm çalışma alanlarını sil", + "settings.workspaces.deleteAll.confirm": "{{count}} çalışma alanının tümü silinsin mi?", + "settings.workspaces.delete.warning": + "Çalışma alanı dizini ve dalı kalıcı olarak kaldırılacak. Silme yalnızca alan temiz, etkin değil ve bağlantılı oturumu yoksa devam eder.", + "settings.workspaces.deleteAll.warning": + "{{project}} içindeki seçili {{count}} çalışma alanı, yalnızca her biri temiz, etkin değil ve bağlantılı oturumu yoksa kalıcı olarak kaldırılacak.", + "settings.workspaces.delete.blocked.active": "Etkin çalışma alanı silinemez.", + "settings.workspaces.delete.blocked.linked": "Bu çalışma alanında bağlantılı oturumlar var ve silinemez.", + "settings.workspaces.section.defaults": "Yeni oturumlar", + "settings.workspaces.default.title": "Varsayılan ortam", + "settings.workspaces.default.description": "Yeni oturumların nerede başlayacağını seçin", + "settings.workspaces.default.lastUsed": "Proje başına son kullanılan", + "settings.workspaces.default.local": "Yerel dizin", + "settings.workspaces.default.new": "Yeni çalışma alanı", "workspace.move.title": "Çalışma alanına taşı", "workspace.move.failed": "Oturum taşınamadı", "workspace.lifecycle.creating": "Çalışma alanı oluşturuluyor", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index f7aa5669d54..93857fe562f 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -1078,6 +1078,31 @@ export const dict = { "session.delete.button": "Видалити сесію", "workspace.new": "Нова робоча область", + "common.viewAll": "Показати все", + "session.new.workspace.fromBranch": "з {{branch}}", + "settings.tab.workspaces": "Робочі простори", + "settings.workspaces.filter.all": "Усі проєкти", + "settings.workspaces.empty": "Немає робочих просторів", + "settings.workspaces.count.one": "{{count}} робочий простір", + "settings.workspaces.count.other": "{{count}} робочих просторів", + "settings.workspaces.sessions.one": "{{count}} сесія в {{project}}", + "settings.workspaces.sessions.other": "{{count}} сесій у {{project}}", + "settings.workspaces.lastActive": "Остання активність: {{time}}", + "settings.workspaces.lastActiveSession": "Останній активний сеанс", + "settings.workspaces.deleteAll": "Видалити всі робочі простори", + "settings.workspaces.deleteAll.confirm": "Видалити всі робочі простори ({{count}})?", + "settings.workspaces.delete.warning": + "Каталог і гілку робочого простору буде видалено назавжди. Видалення виконується лише для чистого, неактивного простору без пов’язаних сеансів.", + "settings.workspaces.deleteAll.warning": + "Вибрані робочі простори ({{count}}) у {{project}} буде видалено назавжди, лише якщо кожен із них чистий, неактивний і не має пов’язаних сеансів.", + "settings.workspaces.delete.blocked.active": "Активний робочий простір не можна видалити.", + "settings.workspaces.delete.blocked.linked": "Цей робочий простір має пов’язані сеанси, тому його не можна видалити.", + "settings.workspaces.section.defaults": "Нові сеанси", + "settings.workspaces.default.title": "Середовище за замовчуванням", + "settings.workspaces.default.description": "Виберіть, де починатимуться нові сеанси", + "settings.workspaces.default.lastUsed": "Останнє використане для кожного проєкту", + "settings.workspaces.default.local": "Локальний каталог", + "settings.workspaces.default.new": "Новий робочий простір", "workspace.move.title": "Перемістити до робочого простору", "workspace.move.failed": "Не вдалося перемістити сеанс", "workspace.lifecycle.creating": "Створення робочого простору", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 42d097a5bf1..ecb7cef6df7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -964,6 +964,31 @@ export const dict = { "session.delete.button": "删除会话", "workspace.new": "新建工作区", + "common.viewAll": "查看全部", + "session.new.workspace.fromBranch": "从 {{branch}}", + "settings.tab.workspaces": "工作区", + "settings.workspaces.filter.all": "所有项目", + "settings.workspaces.empty": "没有工作区", + "settings.workspaces.count.one": "{{count}} 个工作区", + "settings.workspaces.count.other": "{{count}} 个工作区", + "settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 个会话", + "settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 个会话", + "settings.workspaces.lastActive": "上次活跃:{{time}}", + "settings.workspaces.lastActiveSession": "最近活跃的会话", + "settings.workspaces.deleteAll": "删除所有工作区", + "settings.workspaces.deleteAll.confirm": "删除全部 {{count}} 个工作区?", + "settings.workspaces.delete.warning": + "工作区目录和分支将被永久删除。仅当工作区干净、未处于活动状态且没有关联会话时才会继续删除。", + "settings.workspaces.deleteAll.warning": + "仅当 {{project}} 中选定的 {{count}} 个工作区均干净、未处于活动状态且没有关联会话时,才会永久删除它们。", + "settings.workspaces.delete.blocked.active": "无法删除当前活动的工作区。", + "settings.workspaces.delete.blocked.linked": "此工作区有关联会话,无法删除。", + "settings.workspaces.section.defaults": "新会话", + "settings.workspaces.default.title": "默认环境", + "settings.workspaces.default.description": "选择新会话的启动位置", + "settings.workspaces.default.lastUsed": "每个项目上次使用的环境", + "settings.workspaces.default.local": "本地目录", + "settings.workspaces.default.new": "新工作区", "workspace.move.title": "移动到工作区", "workspace.move.failed": "无法移动会话", "workspace.lifecycle.creating": "正在创建工作区", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 60bd3193a3d..a6ebcadd5b3 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -960,6 +960,31 @@ export const dict = { "session.delete.button": "刪除工作階段", "workspace.new": "新增工作區", + "common.viewAll": "查看全部", + "session.new.workspace.fromBranch": "從 {{branch}}", + "settings.tab.workspaces": "工作區", + "settings.workspaces.filter.all": "所有專案", + "settings.workspaces.empty": "沒有工作區", + "settings.workspaces.count.one": "{{count}} 個工作區", + "settings.workspaces.count.other": "{{count}} 個工作區", + "settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 個工作階段", + "settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 個工作階段", + "settings.workspaces.lastActive": "上次活動:{{time}}", + "settings.workspaces.lastActiveSession": "最近活動的工作階段", + "settings.workspaces.deleteAll": "刪除所有工作區", + "settings.workspaces.deleteAll.confirm": "刪除全部 {{count}} 個工作區?", + "settings.workspaces.delete.warning": + "工作區目錄和分支將被永久刪除。只有在工作區乾淨、未處於活動狀態且沒有關聯工作階段時才會繼續刪除。", + "settings.workspaces.deleteAll.warning": + "只有在 {{project}} 中選取的 {{count}} 個工作區都乾淨、未處於活動狀態且沒有關聯工作階段時,才會永久刪除它們。", + "settings.workspaces.delete.blocked.active": "無法刪除目前作用中的工作區。", + "settings.workspaces.delete.blocked.linked": "此工作區有關聯工作階段,無法刪除。", + "settings.workspaces.section.defaults": "新工作階段", + "settings.workspaces.default.title": "預設環境", + "settings.workspaces.default.description": "選擇新工作階段的啟動位置", + "settings.workspaces.default.lastUsed": "每個專案上次使用的環境", + "settings.workspaces.default.local": "本機目錄", + "settings.workspaces.default.new": "新工作區", "workspace.move.title": "移動到工作區", "workspace.move.failed": "無法移動工作階段", "workspace.lifecycle.creating": "正在建立工作區", diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index a07f5cdc4b5..b23640663a0 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -29,13 +29,14 @@ import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-wo import { useTitlebarRightMount } from "@/components/titlebar" import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" -import { useSettingsCommand } from "@/components/settings-dialog" +import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" import { useLocal } from "@/context/local" import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" import { useTabs, type DraftTab } from "@/context/tabs" -import { isWorkspaceSelection } from "@/utils/workspace" +import { isWorkspaceSelection, workspaceDefaultSelection } from "@/utils/workspace" +import { pathKey } from "@/utils/path-key" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -78,6 +79,7 @@ export default function NewSessionPage() { const projectControls = createPromptProjectControls() const rightMount = useTitlebarRightMount() + const openWorkspaces = useSettingsDialog("workspaces") const draft = createMemo(() => tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === searchParams.draftId), ) @@ -87,6 +89,14 @@ export default function NewSessionPage() { tabs.updateDraft(id, { worktree }) } const showWorkspaceBar = createMemo(() => sync().project?.vcs === "git") + const defaultWorktree = createMemo(() => { + const project = sync().project + if (!project) return "main" + return workspaceDefaultSelection( + settings.workspaces.defaultDestination(), + settings.workspaces.lastUsed(sdk().scope, project.id), + ) + }) const selectedWorktree = createMemo(() => { const project = sync().project const worktree = draft()?.worktree @@ -99,7 +109,7 @@ export default function NewSessionPage() { if (worktree) return worktree const project = sync().project if (project && sdk().directory !== project.worktree) return sdk().directory - return "main" + return defaultWorktree() }) const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) @@ -108,9 +118,16 @@ export default function NewSessionPage() { if (worktree === "main" || worktree === "create") return localBranch() return serverSync().child(worktree)[0].vcs?.branch }) + const rememberWorkspaceSelection = (worktree: string) => { + const project = sync().project + if (!project) return + const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree) + settings.workspaces.setLastUsed(sdk().scope, project.id, local ? "local" : "workspace") + } const selectWorktree = (worktree: string) => { const project = sync().project setWorktree(worktree === "main" && project?.worktree !== sdk().directory ? project?.worktree : worktree) + rememberWorkspaceSelection(worktree) } const promptInputV2Controller = usePromptInputV2Controller({ get controls() { @@ -120,7 +137,10 @@ export default function NewSessionPage() { return newSessionWorktree() }, onNewSessionWorktreeReset: () => setWorktree(undefined), - onSubmit: comments.clear, + onSubmit: () => { + rememberWorkspaceSelection(newSessionWorktree()) + comments.clear() + }, }) const projectController = createPromptProjectController({ controls: projectControls, @@ -205,6 +225,7 @@ export default function NewSessionPage() { branch={selectedBranch()} onChange={selectWorktree} onDone={promptInputV2Controller.restoreFocus} + onViewAll={openWorkspaces} />
diff --git a/packages/app/src/utils/workspace.test.ts b/packages/app/src/utils/workspace.test.ts index 2b4cb04200a..d979e5e0118 100644 --- a/packages/app/src/utils/workspace.test.ts +++ b/packages/app/src/utils/workspace.test.ts @@ -1,5 +1,18 @@ import { describe, expect, test } from "bun:test" -import { isWorkspaceDirectory, isWorkspaceSelection } from "./workspace" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { + filterWorkspaceInventory, + inspectWorkspaceDeletion, + isProjectDirectory, + isWorkspaceDirectory, + isWorkspaceSelection, + removeWorkspacesSequentially, + mergeWorkspaceSessionInventory, + runWorkspaceDeleteTransaction, + sessionsForWorkspace, + workspaceDefaultSelection, + workspaceInventory, +} from "./workspace" describe("isWorkspaceDirectory", () => { const project = { @@ -33,3 +46,171 @@ describe("isWorkspaceSelection", () => { expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false) }) }) + +describe("workspaceDefaultSelection", () => { + test("uses the explicit global default", () => { + expect(workspaceDefaultSelection("local", "workspace")).toBe("main") + expect(workspaceDefaultSelection("new", "local")).toBe("create") + }) + + test("falls back to the last mode used in each project", () => { + expect(workspaceDefaultSelection("last-used", "workspace")).toBe("create") + expect(workspaceDefaultSelection("last-used", "local")).toBe("main") + expect(workspaceDefaultSelection("last-used", undefined)).toBe("main") + }) +}) + +describe("isProjectDirectory", () => { + const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] } + + test("accepts local and workspace subdirectories only", () => { + expect(isProjectDirectory(project, "/repo/packages/app")).toBe(true) + expect(isProjectDirectory(project, "/workspaces/feature/packages/app")).toBe(true) + expect(isProjectDirectory(project, "/other/project")).toBe(false) + }) +}) + +test("groups and filters workspace inventory by project", () => { + const inventory = workspaceInventory([ + { id: "a", worktree: "/a", sandboxes: ["/a/one", "/a/two"] }, + { id: "b", worktree: "/b", sandboxes: ["/b/one"] }, + ]) + + expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([ + ["a", "/a/one"], + ["a", "/a/two"], + ["b", "/b/one"], + ]) + expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"]) + expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory) +}) + +test("deletes all workspaces sequentially", async () => { + const calls: string[] = [] + let release = () => {} + const first = new Promise((resolve) => { + release = resolve + }) + const removing = removeWorkspacesSequentially(["one", "two"], async (workspace) => { + calls.push(`start:${workspace}`) + if (workspace === "one") await first + calls.push(`end:${workspace}`) + }) + + await Promise.resolve() + expect(calls).toEqual(["start:one"]) + release() + await removing + expect(calls).toEqual(["start:one", "end:one", "start:two", "end:two"]) +}) + +test("continues sequential deletion after a handled request failure", async () => { + const calls: string[] = [] + await removeWorkspacesSequentially(["one", "two"], async (workspace) => { + calls.push(workspace) + if (workspace === "one") await Promise.reject(new Error("failed")).catch(() => {}) + }) + expect(calls).toEqual(["one", "two"]) +}) + +test("blocks unsafe workspace deletion", () => { + const session = (directory: string) => ({ directory }) as Session + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + activeDirectory: "/workspace/app", + sessions: [], + status: "clean", + }), + ).toBe("active") + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + sessions: [session("/workspace/packages/app")], + status: "clean", + }), + ).toBe("linked") + expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty") + expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "unknown" })).toBe("unknown") + expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe") +}) + +test("groups nested non-archived workspace sessions by latest activity", () => { + const session = (id: string, directory: string, updated: number, archived?: number) => + ({ id, directory, time: { created: 1, updated, archived } }) as Session + const sessions = sessionsForWorkspace( + [ + session("old", "/workspace", 2), + session("nested", "/workspace/packages/app", 3), + session("archived", "/workspace", 4, 5), + session("other", "/other", 6), + ], + "/workspace", + ) + expect(sessions.map((item) => item.id)).toEqual(["nested", "old"]) +}) + +test("merges workspace placement by freshness with authoritative server ties", () => { + const session = (directory: string, updated: number) => + ({ id: "session", directory, time: { created: 1, updated } }) as Session + + expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.directory).toBe( + "/destination", + ) + expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.directory).toBe( + "/destination", + ) + expect(mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.directory).toBe( + "/source", + ) +}) + +test.each(["single", "bulk"])("acquires one atomic %s workspace delete transaction", async () => { + let transaction: "confirm" | number | undefined = "confirm" + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let requests = 0 + const run = (token: number) => + runWorkspaceDeleteTransaction({ + token, + set: (update) => { + transaction = update(transaction) + }, + task: async () => { + requests++ + await gate + }, + }) + + const first = run(1) + const duplicate = run(2) + expect(requests).toBe(1) + expect(transaction as "confirm" | number | undefined).toBe(1) + expect(await duplicate).toBe(false) + release() + expect(await first).toBe(true) + expect(transaction).toBeUndefined() + expect(requests).toBe(1) +}) + +test("workspace delete completion does not unlock a different owner", async () => { + let transaction: "confirm" | number | undefined = "confirm" + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const removing = runWorkspaceDeleteTransaction({ + token: 1, + set: (update) => { + transaction = update(transaction) + }, + task: () => gate, + }) + + transaction = 2 + release() + expect(await removing).toBe(true) + expect(transaction).toBe(2) +}) diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts index b7ec706eb3f..c69b1471faa 100644 --- a/packages/app/src/utils/workspace.ts +++ b/packages/app/src/utils/workspace.ts @@ -1,12 +1,88 @@ import { pathKey } from "@/utils/path-key" +import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings" +import type { Session } from "@opencode-ai/sdk/v2/client" type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] } +export function workspaceInventory(projects: readonly T[]) { + return projects.flatMap((project) => (project.sandboxes ?? []).map((directory) => ({ directory, project }))) +} + +export function filterWorkspaceInventory( + workspaces: readonly T[], + project: string, +) { + if (project === "all") return [...workspaces] + return workspaces.filter((workspace) => workspace.project.id === project) +} + +export function sessionsForWorkspace(sessions: readonly Session[], workspace: string) { + return sessions + .filter((session) => session.time.archived === undefined) + .filter((session) => containsDirectory(workspace, session.directory)) + .toSorted((a, b) => b.time.updated - a.time.updated) +} + +export function mergeWorkspaceSessionInventory(server: readonly Session[], cached: readonly Session[]) { + const sessions = new Map(server.map((session) => [session.id, session])) + cached.forEach((session) => { + const current = sessions.get(session.id) + if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session) + }) + return [...sessions.values()] +} + +export function removeWorkspacesSequentially(workspaces: readonly T[], remove: (workspace: T) => Promise) { + return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve()) +} + +export type WorkspaceDeleteTransaction = "confirm" | number | undefined + +export async function runWorkspaceDeleteTransaction(input: { + token: number + set: (update: (current: WorkspaceDeleteTransaction) => WorkspaceDeleteTransaction) => void + task: () => Promise +}) { + let acquired = false + input.set((current) => { + if (current !== "confirm") return current + acquired = true + return input.token + }) + if (!acquired) return false + try { + await input.task() + return true + } finally { + input.set((current) => (current === input.token ? undefined : current)) + } +} + +export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty" | "unknown" + +export function inspectWorkspaceDeletion(input: { + workspace: string + activeDirectory?: string + sessions: readonly Session[] + status: "clean" | "dirty" | "unknown" +}): WorkspaceDeleteInspection { + if (input.status === "unknown") return "unknown" + if (input.status === "dirty") return "dirty" + if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active" + if (input.sessions.some((session) => containsDirectory(input.workspace, session.directory))) return "linked" + return "safe" +} + export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) { if (!project || containsDirectory(project.worktree, directory)) return false return project.sandboxes?.some((workspace) => containsDirectory(workspace, directory)) ?? false } +export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) { + if (!project) return false + return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory)) +} + export function containsDirectory(parent: string, child: string) { const normalize = (value: string) => { const key = pathKey(value) @@ -23,3 +99,12 @@ export function isWorkspaceSelection(project: WorkspaceProject | undefined, sele if (pathKey(project.worktree) === pathKey(selection)) return true return isWorkspaceDirectory(project, selection) } + +export function workspaceDefaultSelection( + setting: WorkspaceDefaultDestination, + lastUsed: WorkspaceLastUsed | undefined, +) { + if (setting === "local") return "main" + if (setting === "new") return "create" + return lastUsed === "workspace" ? "create" : "main" +}