feat(app): add workspace settings inventory

This commit is contained in:
LukeParkerDev 2026-07-27 13:13:33 +10:00
parent fd599277d6
commit 41bf4b140b
30 changed files with 1854 additions and 13 deletions

View file

@ -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<void>((resolve) => {
release = resolve
})
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(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"

View file

@ -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: {
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
<PromptGitStatus branch={props.branch} from={selected() === "create"} connected={selected() === "create"} />
</>
)
}
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 }) {
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Show when={!props.connected}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name={props.noGit ? "monitor" : "branch"} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<div
class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]"
classList={{ "ml-0.5": props.connected }}
>
<Icon
name={props.noGit ? "monitor" : props.from ? "fork" : "branch"}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>

View file

@ -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<{
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="workspaces">
<Icon name="fork" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
@ -99,6 +104,9 @@ export const DialogSettings: Component<{
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>

View file

@ -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<{
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={workspaceDestinationOptions()}
current={workspaceDestinationOptions().find(
(option) => 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)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}

View file

@ -630,6 +630,189 @@
color: var(--v2-text-text-base);
}
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 32px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: var(--font-family-mono);
font-size: 12px;
font-weight: 440;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta,
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-active {
flex-shrink: 0;
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session > 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);

View file

@ -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<void>) => {
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(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessage={inspectionMessage}
onDelete={() => transact(() => remove(workspace, false, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
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(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.t(
workspaces().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other",
{
count: workspaces().length,
},
)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<SelectV2
appearance="inline"
options={projectOptions()}
current={projectOptions().find((option) => option.id === selectedProject())}
value={(option) => option.id}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && setStore("project", option.id)}
/>
</Show>
<Show when={workspaces().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
{workspace.directory}
</span>
</TooltipV2>
<Show when={lastActive(workspace)}>
{(value) => <span class="settings-v2-workspaces-active">{value()}</span>}
</Show>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session, index) => (
<span class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={index() === 0}>
<span class="settings-v2-workspaces-session-active">
{language.t("settings.workspaces.lastActiveSession")}
</span>
</Show>
</span>
)}
</For>
</div>
</Show>
</div>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", { name: getFilename(workspace.directory) })}
disabled={!!store.transaction || store.deleting === pathKey(workspace.directory)}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
inspectionMessage: (result: WorkspaceDeleteInspection) => string
onDelete: () => Promise<void>
}) {
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 (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
{props.workspace.directory}
<br />
{language.t("settings.workspaces.delete.warning")}
<br />
{description()}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result !== "safe"}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}

View file

@ -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", () => {

View file

@ -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)
}

View file

@ -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<string, WorkspaceLastUsed>
}
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) {

View file

@ -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": "جارٍ إنشاء مساحة العمل",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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 lespace de travail seront supprimés définitivement. La suppression ne continue que sil 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": "Lespace 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 lespace de travail",
"workspace.move.failed": "Impossible de déplacer la session",
"workspace.lifecycle.creating": "Création de lespace de travail",

View file

@ -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": "ワークスペースを作成中",

View file

@ -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": "작업 공간 생성 중",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Создание рабочей области",

View file

@ -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": "กำลังสร้างพื้นที่ทำงาน",

View file

@ -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",

View file

@ -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": "Створення робочого простору",

View file

@ -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": "正在创建工作区",

View file

@ -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": "正在建立工作區",

View file

@ -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}
/>
</Show>
</div>

View file

@ -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<void>((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<void>((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<void>((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)
})

View file

@ -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<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
return projects.flatMap((project) => (project.sandboxes ?? []).map((directory) => ({ directory, project })))
}
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
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<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
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<void>
}) {
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"
}