Run multiple independent workspaces per directory (#1539)
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)
Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.
- protocol: add optional workspaceId to agent/terminal records and a
workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
creation; carry it through agent-manager, projections, storage,
loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
mcp-server, workspace-directory, workspace-registry-model, and
agent-visibility
cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.
Known follow-ups: none.
* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)
- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename
Known follow-ups: none
* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)
- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory
Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage
* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)
- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
workspace before creating any agent, then stamps the agent with that
workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
--worktree mints its own workspace and overrides the ambient
PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
--workspace attach, and env attach against an isolated daemon.
Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
longer relies on it but it is not yet removable.
* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)
- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.
Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.
* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)
- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.
Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.
* fix(protocol): use literal :: separator in terminal subscription key
The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.
* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)
- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
workspace-owned drafts, attachments, or file-explorer state. New docs section in
architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
right sidebar is shared (a directory change appears in both same-dir workspaces)
while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
- MCP child agents created in a new worktree are stamped with the new worktree's
workspaceId (not the parent's, not unstamped), mirroring the session path so
workspaceId-scoped archive can find and tear them down.
- session-store mergeWorkspaces prunes a stale empty-project descriptor when a
workspace lands in that project, so it stops governing the project's metadata.
- CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
--workspace), so worktree resolution never races an existing-workspace select.
New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
- Consolidate the four placeholder server_info.features flags (workspaceOwnership,
workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
workspaceMultiplicity capability gate.
- Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
- Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
- Drop the obsolete workspaceOwnership feature assertion from the same-cwd
isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
- Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.
Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).
* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)
Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.
Categories fixed:
- Mocks -> real dependencies + persisted state: create.test.ts and
mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
with a fake agent client and assert on the stored agent record (workspaceId,
parent label).
- Module-internal spying -> injected seam: acp-agent stopped spying on the
tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
injection seam to ACPAgentClient/ACPAgentSession (production defaults to
terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
recorded children plus observable stream .destroyed state.
- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
tests now create terminals through the public createTerminal API instead of
mutating internals.terminalEntries.
- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
it then asserted against) and now proves only the daemon behaviors the CLI
builds on; flag precedence stays covered in the CLI's own run.test.ts.
- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
expect.poll on the observed snapshot state to remove the race.
- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
the captureWsSessionFrames workspace.title.set.request assertions, relying on
the user-visible rename + reload checks already present.
* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects
Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
When a new workspace is created with an initial agent, optimistically merge it
with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
instead of defaulting into the wrong bucket. The empty-workspace path passes
withInitialAgent: false so a bare workspace keeps its descriptor status.
Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
The reshaped creation flow requires choosing a backing ("New worktree") before
the branch / starting-ref picker is reachable. Branch-picker specs now call
selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
worktree control to drop aria-disabled (it stays disabled until the checkout
status query confirms the project is a git repo) before clicking.
Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
fetch_workspaces response now carries emptyProjects; the toEqual expectation
includes emptyProjects: [].
Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
Out-of-band working-tree writes raced the daemon's debounced filesystem
watcher, so the UI could subscribe before the new file was in the git snapshot.
Force a checkout refresh (same path as the UI's manual refresh) to make the
write authoritative before asserting, removing the timing dependency.
* test(app): expect withInitialAgent:false in empty-workspace create call
The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.
* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance
Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.
- Isolation/backing is now a dropdown (Local vs New worktree) matching the
app's existing dropdown/combobox primitives, replacing the inline
segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
is Local and the picker is hidden, so switching backing no longer causes
the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
backing selector and the reserved Base row.
* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns
- Render the three rows (formStack) at the top, under the "New workspace"
heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
columns, fixed label widths, or reserved horizontal space, with the label
glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
(ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
remove the now-gone workspace-create-title-input usage and title field.
* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select
- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.
* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles
- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.
* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing
- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.
* feat(app): rename Sessions to History (clock icon, Agent history header)
- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)
* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved
The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.
* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)
- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending
* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)
- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
This commit is contained in:
@@ -1,42 +1,45 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Pressable, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
title: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
isGitCheckout: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
export function BranchSwitcher({
|
||||
currentBranchName,
|
||||
title,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
isGitCheckout,
|
||||
testID = "workspace-header-branch-switcher",
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
client,
|
||||
@@ -50,26 +53,19 @@ export function BranchSwitcher({
|
||||
queryClient,
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<View style={styles.titleRow}>
|
||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(Boolean(hovered) || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
styles.trigger,
|
||||
(Boolean(hovered) || pressed) && styles.triggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const branchLeadingSlot = useMemo(
|
||||
() => <GitBranch size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
|
||||
[],
|
||||
);
|
||||
|
||||
const renderBranchOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
|
||||
@@ -86,20 +82,23 @@ export function BranchSwitcher({
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
<Pressable
|
||||
testID="workspace-header-branch-switcher"
|
||||
testID={testID}
|
||||
onPress={handleOpen}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
|
||||
>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
<ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.branchLabel} numberOfLines={1}>
|
||||
{currentBranchName}
|
||||
</Text>
|
||||
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
@@ -123,31 +122,28 @@ export function BranchSwitcher({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
branchSwitcherTrigger: {
|
||||
anchor: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: 0,
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
marginLeft: -theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
},
|
||||
branchSwitcherTriggerHovered: {
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
branchLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -520,7 +520,6 @@ function SidebarContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
hideHeaderRow={!isMobile}
|
||||
enabled={isOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { Clock, FolderPlus, Home, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Dispatch,
|
||||
@@ -116,6 +116,7 @@ interface SidebarSharedProps {
|
||||
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
newWorkspace: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
@@ -242,7 +243,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
router.push(buildHostNewWorkspaceRoute(activeServerId));
|
||||
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
@@ -287,6 +288,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
@@ -800,13 +802,21 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
icon={Plus}
|
||||
label={labels.newWorkspace}
|
||||
onPress={handleNewWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={Clock}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
@@ -973,13 +983,21 @@ function DesktopSidebar({
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
icon={Plus}
|
||||
label={labels.newWorkspace}
|
||||
onPress={handleNewWorkspaceNavigate}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={Clock}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1137,8 +1155,13 @@ const staticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderRow: {
|
||||
position: "relative",
|
||||
sidebarHeaderGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
// Match WorkspacesSectionHeader's paddingTop below the divider so the divider
|
||||
// sits visually centered between the Sessions row and the Workspaces header.
|
||||
paddingBottom: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -73,13 +71,7 @@ import {
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -121,14 +113,9 @@ import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
} from "@/workspace/workspace-archive";
|
||||
import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
|
||||
import {
|
||||
isWeb as platformIsWeb,
|
||||
isNative as platformIsNative,
|
||||
@@ -184,40 +171,6 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
|
||||
function getWorktreeArchiveWarningLabels(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): WorktreeArchiveWarningLabels {
|
||||
return {
|
||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
uncommittedChangesWithDiff: (diffStat) =>
|
||||
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
|
||||
addedLine: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.addedLine"
|
||||
: "workspace.git.actions.archiveWarning.addedLines",
|
||||
{ count },
|
||||
),
|
||||
deletedLine: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.deletedLine"
|
||||
: "workspace.git.actions.archiveWarning.deletedLines",
|
||||
{ count },
|
||||
),
|
||||
unpushedCommit: (count) =>
|
||||
t(
|
||||
count === 1
|
||||
? "workspace.git.actions.archiveWarning.unpushedCommit"
|
||||
: "workspace.git.actions.archiveWarning.unpushedCommits",
|
||||
{ count },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
@@ -1531,14 +1484,12 @@ function WorkspaceRowWithMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1548,7 +1499,7 @@ function WorkspaceRowWithMenu({
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1557,102 +1508,18 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
const archiveController = useWorkspaceArchive({
|
||||
workspace,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
},
|
||||
getWorktreeArchiveWarningLabels(t),
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!archiveDirectory) {
|
||||
toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
redirectAfterArchive();
|
||||
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed");
|
||||
toast.error(message);
|
||||
});
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
}, [archiveWorktreeAfterConfirmation]);
|
||||
|
||||
const hideWorkspaceAfterConfirmation = useCallback(async () => {
|
||||
if (isArchivingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("sidebar.workspace.confirmations.hideTitle"),
|
||||
message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
|
||||
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
|
||||
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
void hideWorkspaceAfterConfirmation();
|
||||
}, [hideWorkspaceAfterConfirmation]);
|
||||
archiveController.beginArchive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -1674,31 +1541,20 @@ function WorkspaceRowWithMenu({
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.name]);
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName"));
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
onSuccess: async ({ targetCwd }) => {
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: workspace.serverId,
|
||||
cwd: targetCwd,
|
||||
});
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1712,20 +1568,11 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(slugify(value));
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const validateRenameSlug = useCallback(
|
||||
(value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1743,11 +1590,7 @@ function WorkspaceRowWithMenu({
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
if (isWorktree) {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
} else {
|
||||
handleArchiveWorkspace();
|
||||
}
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -1766,32 +1609,33 @@ function WorkspaceRowWithMenu({
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={
|
||||
isWorktree
|
||||
? t("sidebar.workspace.actions.archiveWorktree")
|
||||
: t("sidebar.workspace.actions.hideFromSidebar")
|
||||
}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={
|
||||
isWorktree
|
||||
? t("sidebar.workspace.actions.archiving")
|
||||
: t("sidebar.workspace.actions.hiding")
|
||||
}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={getWorkspaceArchiveStatus(
|
||||
isWorktree,
|
||||
worktreeArchiveStatus,
|
||||
isHidingWorkspace,
|
||||
)}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorktreeDeletePrompt
|
||||
visible={archiveController.deletePromptOpen}
|
||||
workspaceName={workspace.name}
|
||||
onKeep={archiveController.confirmKeepOnDisk}
|
||||
onDelete={archiveController.confirmDeleteFromDisk}
|
||||
onCancel={archiveController.cancelDeletePrompt}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
@@ -1800,242 +1644,6 @@ function WorkspaceRowWithMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenuContent({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("sidebar.workspace.confirmations.hideTitle"),
|
||||
message: t("sidebar.workspace.confirmations.hideMessage", {
|
||||
workspaceName: workspace.name,
|
||||
}),
|
||||
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
|
||||
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron={null}
|
||||
onPress={onPress}
|
||||
serverId={null}
|
||||
canCreateWorktree={false}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
menuController={contextMenu}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
||||
pendingLabel={t("sidebar.workspace.actions.hiding")}
|
||||
destructive
|
||||
onSelect={handleArchiveWorkspace}
|
||||
>
|
||||
{t("sidebar.workspace.actions.hideFromSidebar")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenu(props: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<NonGitProjectRowWithMenuContent {...props} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FlattenedProjectRow({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
rowModel,
|
||||
onPress,
|
||||
serverId,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
isProjectActive = false,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: "workspace_link" }>;
|
||||
onPress: () => void;
|
||||
serverId: string | null;
|
||||
onWorkspacePress?: () => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
const selected = isWorkspaceSelected({
|
||||
selection: activeWorkspaceSelection,
|
||||
serverId,
|
||||
workspaceId: rowModel.workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
menuController={null}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceRowItemProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
shortcutNumber: number | null;
|
||||
@@ -2331,84 +1939,49 @@ function ProjectBlock({
|
||||
})();
|
||||
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
|
||||
|
||||
const flattenedRowWorkspaceId =
|
||||
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
|
||||
const handleFlattenedRowPress = useCallback(() => {
|
||||
if (!serverId || !flattenedRowWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
|
||||
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
|
||||
|
||||
const handleToggleCollapsed = useCallback(() => {
|
||||
onToggleCollapsed(project.projectKey);
|
||||
}, [onToggleCollapsed, project.projectKey]);
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{rowModel.kind === "workspace_link" ? (
|
||||
<FlattenedProjectRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
rowModel={rowModel}
|
||||
onPress={handleFlattenedRowPress}
|
||||
serverId={serverId}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={active}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={null}
|
||||
selected={false}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={null}
|
||||
selected={false}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{!collapsed && project.workspaces.length > 0 ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact";
|
||||
|
||||
interface SidebarHeaderRowProps {
|
||||
icon: LucideIcon;
|
||||
@@ -12,14 +19,15 @@ interface SidebarHeaderRowProps {
|
||||
testID?: string;
|
||||
nativeID?: string;
|
||||
accessibilityLabel?: string;
|
||||
/**
|
||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a workspace-row-height row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-of-sidebar header row: a sidebar-height pressable with an icon + label
|
||||
* and a full-width border separator beneath. Used as the first element of a
|
||||
* sidebar (workspace "Sessions", settings "Back to workspace"). Owns its own
|
||||
* separator line so both sidebars converge on the same edge and padding.
|
||||
*/
|
||||
export function SidebarHeaderRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -28,8 +36,14 @@ export function SidebarHeaderRow({
|
||||
testID,
|
||||
nativeID,
|
||||
accessibilityLabel,
|
||||
variant = "header",
|
||||
}: SidebarHeaderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => (variant === "compact" ? styles.containerCompact : styles.container),
|
||||
[variant],
|
||||
);
|
||||
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -42,26 +56,21 @@ export function SidebarHeaderRow({
|
||||
const renderChildren = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
const isHighlighted = Boolean(state.hovered) || isActive;
|
||||
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<Icon size={theme.iconSize.md} color={iconColor} />
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
Icon,
|
||||
isActive,
|
||||
label,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.md,
|
||||
],
|
||||
[ThemedIcon, isActive, label],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={containerStyle}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
@@ -103,10 +112,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
},
|
||||
containerCompact: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
},
|
||||
button: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||
// compact header entries sit tight against the workspace list below.
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -115,7 +132,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
label: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -35,17 +35,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -188,7 +185,10 @@ function StatusGroupList({
|
||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
||||
<View style={styles.statusWorkspaceListContainer}>
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{group.rows.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
@@ -348,14 +348,12 @@ function StatusWorkspaceRowWithMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
@@ -365,7 +363,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -377,63 +375,16 @@ function StatusWorkspaceRowWithMenu({
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
if (isArchiving) return;
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
redirectAfterArchive();
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to archive worktree");
|
||||
});
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
const archiveController = useWorkspaceArchive({
|
||||
workspace,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const hideWorkspaceAfterConfirmation = useCallback(async () => {
|
||||
if (isArchivingWorkspace) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error(t("workspace.terminal.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) return;
|
||||
archiveController.beginArchive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -456,24 +407,10 @@ function StatusWorkspaceRowWithMenu({
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
onSuccess: async ({ targetCwd }) => {
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: workspace.serverId,
|
||||
cwd: targetCwd,
|
||||
});
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -481,15 +418,10 @@ function StatusWorkspaceRowWithMenu({
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(slugify(value));
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? "Invalid branch name";
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
@@ -508,19 +440,15 @@ function StatusWorkspaceRowWithMenu({
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
if (isWorktree) {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
} else {
|
||||
void hideWorkspaceAfterConfirmation();
|
||||
}
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = archiveStatus;
|
||||
} else if (isArchivingWorkspace) {
|
||||
computedArchiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
@@ -534,24 +462,29 @@ function StatusWorkspaceRowWithMenu({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorktreeDeletePrompt
|
||||
visible={archiveController.deletePromptOpen}
|
||||
workspaceName={workspace.name}
|
||||
onKeep={archiveController.confirmKeepOnDisk}
|
||||
onDelete={archiveController.confirmDeleteFromDisk}
|
||||
onCancel={archiveController.cancelDeletePrompt}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dimensions, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink, GitBranch } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -286,6 +286,18 @@ function WorkspaceHoverCardContent({
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{workspace.currentBranch && workspace.currentBranch !== workspace.name ? (
|
||||
<View style={styles.cardBranchRow}>
|
||||
<ThemedGitBranch size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text
|
||||
style={styles.cardBranchText}
|
||||
numberOfLines={1}
|
||||
testID="hover-card-workspace-branch"
|
||||
>
|
||||
{workspace.currentBranch}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{prHint || workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
{workspace.diffStat ? (
|
||||
@@ -309,6 +321,7 @@ function WorkspaceHoverCardContent({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
@@ -471,6 +484,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
cardBranchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
cardBranchText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
|
||||
@@ -734,6 +734,8 @@ interface ComposerProps {
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional accessibility label for the primary submit button. */
|
||||
submitButtonAccessibilityLabel?: string;
|
||||
/** Optional testID for the primary submit button. */
|
||||
submitButtonTestID?: string;
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -951,6 +953,7 @@ export function Composer({
|
||||
hasExternalContent = false,
|
||||
allowEmptySubmit = false,
|
||||
submitButtonAccessibilityLabel,
|
||||
submitButtonTestID,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
submitBehavior = "clear",
|
||||
@@ -1864,6 +1867,7 @@ export function Composer({
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface MessageInputProps {
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional accessibility label for the primary submit button. */
|
||||
submitButtonAccessibilityLabel?: string;
|
||||
/** Optional testID for the primary submit button. */
|
||||
submitButtonTestID?: string;
|
||||
submitIcon?: "arrow" | "return";
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -784,6 +786,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
@@ -797,6 +800,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitButtonTestID: string | undefined;
|
||||
buttonIconSize: number;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
@@ -809,6 +813,7 @@ function SendButtonTooltip({
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
testID={submitButtonTestID}
|
||||
style={sendButtonCombinedStyle}
|
||||
>
|
||||
<SendButtonContent
|
||||
@@ -1132,6 +1137,7 @@ interface ResolvedMessageInputProps {
|
||||
hasExternalContent: boolean;
|
||||
allowEmptySubmit: boolean;
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
submitButtonTestID: string | undefined;
|
||||
submitIcon: "arrow" | "return";
|
||||
isSubmitDisabled: boolean;
|
||||
isSubmitLoading: boolean;
|
||||
@@ -1172,6 +1178,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
hasExternalContent: props.hasExternalContent ?? false,
|
||||
allowEmptySubmit: props.allowEmptySubmit ?? false,
|
||||
submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel,
|
||||
submitButtonTestID: props.submitButtonTestID,
|
||||
submitIcon: props.submitIcon ?? "arrow",
|
||||
isSubmitDisabled: props.isSubmitDisabled ?? false,
|
||||
isSubmitLoading: props.isSubmitLoading ?? false,
|
||||
@@ -1220,6 +1227,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
submitButtonAccessibilityLabel,
|
||||
submitButtonTestID,
|
||||
submitIcon,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
@@ -1872,6 +1880,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
sendKeys={DEFAULT_SEND_KEYS}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
|
||||
@@ -42,7 +42,9 @@ import {
|
||||
type MessageEntry,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
type EmptyProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
normalizeEmptyProjectDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
@@ -473,6 +475,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
|
||||
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
|
||||
@@ -540,6 +544,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -561,6 +566,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
// Empty project parents only ride on the first page.
|
||||
for (const project of payload.emptyProjects ?? []) {
|
||||
const descriptor = normalizeEmptyProjectDescriptor(project);
|
||||
emptyProjects.set(descriptor.projectId, descriptor);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
}
|
||||
@@ -573,9 +584,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
setWorkspaces(serverId, workspaces);
|
||||
setEmptyProjects(serverId, emptyProjects.values());
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
},
|
||||
[client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces],
|
||||
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
|
||||
);
|
||||
|
||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||
@@ -1290,6 +1302,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
if (message.payload.emptyProject) {
|
||||
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
@@ -1712,6 +1727,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
mergeWorkspaces,
|
||||
removeWorkspace,
|
||||
removeWorkspaceSetup,
|
||||
addEmptyProject,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setHasHydratedAgents,
|
||||
|
||||
@@ -57,7 +57,11 @@ function GitActionMenuItem({
|
||||
<View>
|
||||
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={`changes-menu-${action.id}`}
|
||||
testID={
|
||||
action.id === "archive-worktree"
|
||||
? "workspace-archive-action"
|
||||
: `changes-menu-${action.id}`
|
||||
}
|
||||
leading={action.icon}
|
||||
trailing={trailing}
|
||||
disabled={action.disabled}
|
||||
|
||||
@@ -258,6 +258,8 @@ interface CheckoutGitActionsStoreState {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
workspaceId?: string;
|
||||
deleteWorktreeFromDisk?: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -497,7 +499,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId, deleteWorktreeFromDisk }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -519,7 +521,11 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath,
|
||||
...(workspaceId !== undefined ? { workspaceId } : {}),
|
||||
deleteWorktreeFromDisk,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Download,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
ListChevronsDownUp,
|
||||
@@ -80,6 +79,7 @@ import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -1086,7 +1086,6 @@ interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
hideHeaderRow?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -1112,7 +1111,6 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
interface DiffLayoutToggleGroupProps {
|
||||
@@ -1500,6 +1498,7 @@ interface DerivedStatusState {
|
||||
baseRef: string | undefined;
|
||||
hasUncommittedChanges: boolean;
|
||||
actionsDisabled: boolean;
|
||||
currentBranchName: string | null;
|
||||
}
|
||||
|
||||
function deriveStatusState({
|
||||
@@ -1517,6 +1516,8 @@ function deriveStatusState({
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
return {
|
||||
gitStatus,
|
||||
isGit,
|
||||
@@ -1525,6 +1526,7 @@ function deriveStatusState({
|
||||
baseRef,
|
||||
hasUncommittedChanges,
|
||||
actionsDisabled,
|
||||
currentBranchName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1580,13 +1582,7 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean
|
||||
return input.paneEnabled && input.isGit;
|
||||
}
|
||||
|
||||
export function GitDiffPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
hideHeaderRow,
|
||||
enabled,
|
||||
}: GitDiffPaneProps) {
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -1684,7 +1680,8 @@ export function GitDiffPane({
|
||||
error: statusError,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
|
||||
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
|
||||
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, currentBranchName } =
|
||||
statusState;
|
||||
|
||||
const reviewDraftScopeKey = useMemo(
|
||||
() =>
|
||||
@@ -2106,7 +2103,11 @@ export function GitDiffPane({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
|
||||
const { gitActions, branchLabel, worktreeDeletePrompt } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: gitActionsIcons,
|
||||
});
|
||||
const committedDiffDescription = useMemo(
|
||||
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
||||
[baseRefLabel, branchLabel],
|
||||
@@ -2152,15 +2153,18 @@ export function GitDiffPane({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{!hideHeaderRow ? (
|
||||
{isGit && (currentBranchName || isMobile) ? (
|
||||
<View style={styles.header} testID="changes-header">
|
||||
<View style={styles.headerLeft}>
|
||||
<ThemedGitBranch size={16} uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId ?? cwd}
|
||||
workspaceDirectory={cwd}
|
||||
isGitCheckout={isGit}
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
||||
{worktreeDeletePrompt}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -2262,19 +2266,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -54,6 +55,62 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string):
|
||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||
}
|
||||
|
||||
// The header archive only appears for Paseo-owned worktrees. When this is the
|
||||
// last active workspace referencing the worktree directory, offer to remove it
|
||||
// from disk; otherwise a sibling workspace still needs the directory.
|
||||
function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean {
|
||||
let references = 0;
|
||||
for (const candidate of workspaces) {
|
||||
if (candidate.workspaceDirectory === worktreePath) {
|
||||
references += 1;
|
||||
}
|
||||
}
|
||||
return references <= 1;
|
||||
}
|
||||
|
||||
// Owns the inline keep/delete prompt for the last-reference worktree case so the
|
||||
// archive flow stays a single decision point and `useGitActions` keeps a flat
|
||||
// shape.
|
||||
function useWorktreeDeletePrompt(
|
||||
runArchive: (worktreePath: string, deleteWorktreeFromDisk: boolean) => void,
|
||||
): {
|
||||
open: (input: { worktreePath: string; workspaceName: string }) => void;
|
||||
element: ReactElement;
|
||||
} {
|
||||
const [state, setState] = useState<{ worktreePath: string; workspaceName: string } | null>(null);
|
||||
|
||||
const resolve = useCallback(
|
||||
(deleteWorktreeFromDisk: boolean) => {
|
||||
const prompt = state;
|
||||
setState(null);
|
||||
if (prompt) {
|
||||
runArchive(prompt.worktreePath, deleteWorktreeFromDisk);
|
||||
}
|
||||
},
|
||||
[runArchive, state],
|
||||
);
|
||||
|
||||
const onKeep = useCallback(() => resolve(false), [resolve]);
|
||||
const onDelete = useCallback(() => resolve(true), [resolve]);
|
||||
const onCancel = useCallback(() => setState(null), []);
|
||||
const open = useCallback(
|
||||
(input: { worktreePath: string; workspaceName: string }) => setState(input),
|
||||
[],
|
||||
);
|
||||
|
||||
const element = (
|
||||
<WorktreeDeletePrompt
|
||||
visible={state !== null}
|
||||
workspaceName={state?.workspaceName ?? ""}
|
||||
onKeep={onKeep}
|
||||
onDelete={onDelete}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
return { open, element };
|
||||
}
|
||||
|
||||
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | null;
|
||||
|
||||
interface DeriveGitActionsStateArgs {
|
||||
@@ -162,6 +219,9 @@ interface UseGitActionsResult {
|
||||
gitActions: GitActions;
|
||||
branchLabel: string;
|
||||
isGit: boolean;
|
||||
// Inline keep/delete confirmation for archiving the last reference to a
|
||||
// Paseo-owned worktree. Consumers must render this so the prompt is visible.
|
||||
worktreeDeletePrompt: ReactElement | null;
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
@@ -471,6 +531,33 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
});
|
||||
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]);
|
||||
|
||||
const runArchiveWorktreeRecord = useCallback(
|
||||
(worktreePath: string, deleteWorktreeFromDisk: boolean) => {
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceList = Array.from(workspaces?.values() ?? []);
|
||||
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
});
|
||||
const redirectRoute = archivedWorkspaceId
|
||||
? buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
})
|
||||
: buildHostRootRoute(serverId);
|
||||
router.replace(redirectRoute as Href);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch(
|
||||
(err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
|
||||
},
|
||||
);
|
||||
},
|
||||
[cwd, runArchiveWorktree, serverId, t, toastActionError],
|
||||
);
|
||||
|
||||
const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord);
|
||||
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
@@ -496,32 +583,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
});
|
||||
const redirectRoute = archivedWorkspaceId
|
||||
? buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
})
|
||||
: buildHostRootRoute(serverId);
|
||||
router.replace(redirectRoute as Href);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
|
||||
});
|
||||
if (isLastWorktreeReference(workspaceList, worktreePath)) {
|
||||
worktreeDeletePrompt.open({ worktreePath, workspaceName: workspace?.name ?? branchLabel });
|
||||
return;
|
||||
}
|
||||
runArchiveWorktreeRecord(worktreePath, false);
|
||||
}, [
|
||||
branchLabel,
|
||||
cwd,
|
||||
gitStatus?.aheadOfOrigin,
|
||||
gitStatus?.isDirty,
|
||||
runArchiveWorktree,
|
||||
runArchiveWorktreeRecord,
|
||||
serverId,
|
||||
status?.cwd,
|
||||
t,
|
||||
toast,
|
||||
toastActionError,
|
||||
worktreeDeletePrompt,
|
||||
]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
@@ -732,7 +808,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
baseRef,
|
||||
]);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
return { gitActions, branchLabel, isGit, worktreeDeletePrompt: worktreeDeletePrompt.element };
|
||||
}
|
||||
|
||||
function translateGitActions(
|
||||
@@ -48,11 +48,20 @@ const ICONS = {
|
||||
};
|
||||
|
||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
||||
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS });
|
||||
const { gitActions, isGit, worktreeDeletePrompt } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: ICONS,
|
||||
});
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
return (
|
||||
<>
|
||||
<GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />
|
||||
{worktreeDeletePrompt}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ function ws(
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? "main",
|
||||
title: input.title ?? null,
|
||||
currentBranch: input.currentBranch ?? null,
|
||||
statusBucket: input.statusBucket ?? "done",
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
archivingAt: null,
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface SidebarWorkspaceEntry {
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
// Raw user-set title (null when the name is derived from branch/directory).
|
||||
// Prefills the rename input and signals whether a reset is available.
|
||||
title: string | null;
|
||||
// Checkout branch (null when not a git checkout or detached HEAD).
|
||||
currentBranch: string | null;
|
||||
statusBucket: SidebarStateBucket;
|
||||
statusEnteredAt: Date | null;
|
||||
archivingAt: string | null;
|
||||
@@ -61,6 +66,8 @@ function createStructuralWorkspaceEntry(input: {
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
|
||||
@@ -48,6 +48,7 @@ function createRuntime(hosts: HostFixture[]): RuntimeAdapter {
|
||||
return {
|
||||
requestId: `req-${host.serverId}`,
|
||||
entries: workspaces,
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
} satisfies FetchWorkspacesResult;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
|
||||
export function useSidebarShortcutModel(input: {
|
||||
@@ -33,13 +32,9 @@ export function useSidebarShortcutModel(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const collapsibleProjectKeys = new Set(
|
||||
projects
|
||||
.filter((project) => !isSidebarProjectFlattened(project))
|
||||
.map((project) => project.projectKey),
|
||||
);
|
||||
const projectKeys = new Set(projects.map((project) => project.projectKey));
|
||||
for (const key of collapsedProjectKeys) {
|
||||
if (!collapsibleProjectKeys.has(key)) {
|
||||
if (!projectKeys.has(key)) {
|
||||
setProjectCollapsed(key, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ export {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = currentBranch.trim();
|
||||
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
@@ -51,6 +59,8 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
title: input.workspace.title ?? null,
|
||||
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
archivingAt: input.workspace.archivingAt,
|
||||
|
||||
@@ -290,7 +290,7 @@ describe("translation resources", () => {
|
||||
});
|
||||
|
||||
it("includes sessions and agent list keys for the Batch 4H migration", () => {
|
||||
expect(en.sessions.title).toBe("Sessions");
|
||||
expect(en.sessions.title).toBe("Agent history");
|
||||
expect(en.sessions.empty).toBe("No sessions yet");
|
||||
expect(en.sessions.actions.loadMore).toBe("Load more");
|
||||
expect(en.agentList.fallbackTitle).toBe("New session");
|
||||
@@ -353,7 +353,7 @@ describe("translation resources", () => {
|
||||
expect(en.sidebar.actions.home).toBe("Home");
|
||||
expect(en.sidebar.actions.settings).toBe("Settings");
|
||||
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
||||
expect(en.sidebar.sections.sessions).toBe("Sessions");
|
||||
expect(en.sidebar.sections.sessions).toBe("History");
|
||||
expect(en.sidebar.workspace.actions.newWorkspace).toBe("New workspace");
|
||||
expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe(
|
||||
"Create a new workspace for {{projectName}}",
|
||||
|
||||
@@ -206,7 +206,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "الجلسات",
|
||||
title: "سجل الوكلاء",
|
||||
empty: "لا توجد جلسات بعد",
|
||||
actions: {
|
||||
loadMore: "تحميل المزيد",
|
||||
@@ -746,12 +746,13 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "إضافة مشروع",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
home: "بيت",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
},
|
||||
sections: {
|
||||
sessions: "الجلسات",
|
||||
sessions: "السجل",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "إعداد البرامج النصية لشجرة العمل",
|
||||
@@ -809,6 +810,12 @@ export const ar: TranslationResources = {
|
||||
hideConfirm: "يخفي",
|
||||
cancel: "يلغي",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "أرشفة مساحة العمل",
|
||||
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
|
||||
keep: "الاحتفاظ على القرص",
|
||||
delete: "حذف",
|
||||
},
|
||||
rename: {
|
||||
title: "إعادة تسمية مساحة العمل",
|
||||
submit: "إعادة تسمية",
|
||||
@@ -827,6 +834,17 @@ export const ar: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "مساحة عمل جديدة",
|
||||
create: "يخلق",
|
||||
backing: {
|
||||
local: "محلي",
|
||||
worktree: "شجرة عمل جديدة",
|
||||
label: "العزل",
|
||||
},
|
||||
fields: {
|
||||
project: "المشروع",
|
||||
base: "الأساس",
|
||||
baseNotApplicable: "غير قابل للتطبيق",
|
||||
},
|
||||
titlePlaceholder: "العنوان (اختياري)",
|
||||
errors: {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
createWorktreeFailed: "فشل في إنشاء شجرة العمل",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const en = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
title: "Agent history",
|
||||
empty: "No sessions yet",
|
||||
actions: {
|
||||
loadMore: "Load more",
|
||||
@@ -752,12 +752,13 @@ export const en = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "Add project",
|
||||
newWorkspace: "New workspace",
|
||||
home: "Home",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Sessions",
|
||||
sessions: "History",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Set up worktree scripts",
|
||||
@@ -815,6 +816,12 @@ export const en = {
|
||||
hideConfirm: "Hide",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archive workspace",
|
||||
message: "Also remove the worktree from disk?",
|
||||
keep: "Keep on disk",
|
||||
delete: "Delete",
|
||||
},
|
||||
rename: {
|
||||
title: "Rename workspace",
|
||||
submit: "Rename",
|
||||
@@ -833,6 +840,17 @@ export const en = {
|
||||
newWorkspace: {
|
||||
title: "New workspace",
|
||||
create: "Create",
|
||||
backing: {
|
||||
local: "Local",
|
||||
worktree: "New worktree",
|
||||
label: "Isolation",
|
||||
},
|
||||
fields: {
|
||||
project: "Project",
|
||||
base: "Base",
|
||||
baseNotApplicable: "Not applicable",
|
||||
},
|
||||
titlePlaceholder: "Title (optional)",
|
||||
errors: {
|
||||
hostDisconnected: "Host is not connected",
|
||||
createWorktreeFailed: "Failed to create worktree",
|
||||
|
||||
@@ -209,7 +209,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Sesiones",
|
||||
title: "Historial de agentes",
|
||||
empty: "Aún no hay sesiones",
|
||||
actions: {
|
||||
loadMore: "Cargar más",
|
||||
@@ -772,12 +772,13 @@ export const es: TranslationResources = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "Agregar proyecto",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
home: "Hogar",
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Sesiones",
|
||||
sessions: "Historial",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Configurar secuencias de comandos del árbol de trabajo",
|
||||
@@ -835,6 +836,12 @@ export const es: TranslationResources = {
|
||||
hideConfirm: "Esconder",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archivar espacio de trabajo",
|
||||
message: "¿También eliminar el worktree del disco?",
|
||||
keep: "Conservar en disco",
|
||||
delete: "Eliminar",
|
||||
},
|
||||
rename: {
|
||||
title: "Cambiar nombre del espacio de trabajo",
|
||||
submit: "Rebautizar",
|
||||
@@ -853,6 +860,17 @@ export const es: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "Nuevo espacio de trabajo",
|
||||
create: "Crear",
|
||||
backing: {
|
||||
local: "Local",
|
||||
worktree: "Nuevo worktree",
|
||||
label: "Aislamiento",
|
||||
},
|
||||
fields: {
|
||||
project: "Proyecto",
|
||||
base: "Base",
|
||||
baseNotApplicable: "No aplicable",
|
||||
},
|
||||
titlePlaceholder: "Título (opcional)",
|
||||
errors: {
|
||||
hostDisconnected: "Hostno está conectado",
|
||||
createWorktreeFailed: "No se pudo crear el árbol de trabajo",
|
||||
|
||||
@@ -209,7 +209,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Séances",
|
||||
title: "Historique des agents",
|
||||
empty: "Aucune séance pour l'instant",
|
||||
actions: {
|
||||
loadMore: "Charger plus",
|
||||
@@ -771,12 +771,13 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "Ajouter un projet",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
home: "Maison",
|
||||
settings: "Paramètres",
|
||||
closeSidebar: "Fermer la barre latérale",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Séances",
|
||||
sessions: "Historique",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Configurer les scripts d'arbre de travail",
|
||||
@@ -834,6 +835,12 @@ export const fr: TranslationResources = {
|
||||
hideConfirm: "Cacher",
|
||||
cancel: "Annuler",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archiver l'espace de travail",
|
||||
message: "Supprimer aussi le worktree du disque?",
|
||||
keep: "Conserver sur le disque",
|
||||
delete: "Supprimer",
|
||||
},
|
||||
rename: {
|
||||
title: "Renommer l'espace de travail",
|
||||
submit: "Rebaptiser",
|
||||
@@ -852,6 +859,17 @@ export const fr: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "Nouvel espace de travail",
|
||||
create: "Créer",
|
||||
backing: {
|
||||
local: "Local",
|
||||
worktree: "Nouveau worktree",
|
||||
label: "Isolation",
|
||||
},
|
||||
fields: {
|
||||
project: "Projet",
|
||||
base: "Base",
|
||||
baseNotApplicable: "Non applicable",
|
||||
},
|
||||
titlePlaceholder: "Titre (facultatif)",
|
||||
errors: {
|
||||
hostDisconnected: "Hostn'est pas connecté",
|
||||
createWorktreeFailed: "Échec de la création de l'arbre de travail",
|
||||
|
||||
@@ -208,7 +208,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Сессии",
|
||||
title: "История агентов",
|
||||
empty: "Сеансов пока нет",
|
||||
actions: {
|
||||
loadMore: "Загрузить больше",
|
||||
@@ -764,12 +764,13 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "Добавить проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
home: "Дом",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Сессии",
|
||||
sessions: "История",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Настройка сценариев рабочего дерева",
|
||||
@@ -827,6 +828,12 @@ export const ru: TranslationResources = {
|
||||
hideConfirm: "Скрывать",
|
||||
cancel: "Отмена",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Архивировать рабочее пространство",
|
||||
message: "Также удалить рабочее дерево с диска?",
|
||||
keep: "Оставить на диске",
|
||||
delete: "Удалить",
|
||||
},
|
||||
rename: {
|
||||
title: "Переименовать рабочую область",
|
||||
submit: "Переименовать",
|
||||
@@ -845,6 +852,17 @@ export const ru: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "Новое рабочее пространство",
|
||||
create: "Создавать",
|
||||
backing: {
|
||||
local: "Локально",
|
||||
worktree: "Новый worktree",
|
||||
label: "Изоляция",
|
||||
},
|
||||
fields: {
|
||||
project: "Проект",
|
||||
base: "База",
|
||||
baseNotApplicable: "Неприменимо",
|
||||
},
|
||||
titlePlaceholder: "Название (необязательно)",
|
||||
errors: {
|
||||
hostDisconnected: "Host не подключен",
|
||||
createWorktreeFailed: "Не удалось создать рабочее дерево.",
|
||||
|
||||
@@ -206,7 +206,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "会话",
|
||||
title: "Agent 历史",
|
||||
empty: "还没有会话",
|
||||
actions: {
|
||||
loadMore: "加载更多",
|
||||
@@ -740,12 +740,13 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
actions: {
|
||||
addProject: "添加 project",
|
||||
newWorkspace: "新建工作区",
|
||||
home: "首页",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
},
|
||||
sections: {
|
||||
sessions: "会话",
|
||||
sessions: "历史",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "设置 worktree scripts",
|
||||
@@ -800,6 +801,12 @@ export const zhCN: TranslationResources = {
|
||||
hideConfirm: "隐藏",
|
||||
cancel: "取消",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "归档 workspace",
|
||||
message: "同时从磁盘删除 worktree?",
|
||||
keep: "保留在磁盘上",
|
||||
delete: "删除",
|
||||
},
|
||||
rename: {
|
||||
title: "重命名 workspace",
|
||||
submit: "重命名",
|
||||
@@ -818,6 +825,17 @@ export const zhCN: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "新建 workspace",
|
||||
create: "创建",
|
||||
backing: {
|
||||
local: "本地",
|
||||
worktree: "新建 worktree",
|
||||
label: "隔离",
|
||||
},
|
||||
fields: {
|
||||
project: "项目",
|
||||
base: "基线",
|
||||
baseNotApplicable: "不适用",
|
||||
},
|
||||
titlePlaceholder: "标题(可选)",
|
||||
errors: {
|
||||
hostDisconnected: "Host 未连接",
|
||||
createWorktreeFailed: "创建 worktree 失败",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
@@ -41,13 +42,19 @@ function useTerminalPanelDescriptor(
|
||||
const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId);
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
queryKey: buildTerminalsQueryKey(
|
||||
context.serverId,
|
||||
workspaceDirectory,
|
||||
context.workspaceId || null,
|
||||
),
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Workspace directory not found");
|
||||
}
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
return client.listTerminals(workspaceDirectory, undefined, {
|
||||
workspaceId: context.workspaceId || undefined,
|
||||
});
|
||||
},
|
||||
staleTime: 5_000,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
export interface WorkspaceStructureProject {
|
||||
@@ -45,9 +45,11 @@ function compareWorkspaceStructureProjects(
|
||||
export function buildWorkspaceStructureProjects(input: {
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
emptyProjects?: Iterable<EmptyProjectDescriptor>;
|
||||
}): WorkspaceStructureProject[] {
|
||||
const workspaceList = Array.from(input.workspaces);
|
||||
if (workspaceList.length === 0) {
|
||||
const emptyProjectList = Array.from(input.emptyProjects ?? []);
|
||||
if (workspaceList.length === 0 && emptyProjectList.length === 0) {
|
||||
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,18 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
}
|
||||
>();
|
||||
|
||||
for (const emptyProject of emptyProjectList) {
|
||||
byProject.set(emptyProject.projectId, {
|
||||
projectKey: emptyProject.projectId,
|
||||
projectName:
|
||||
emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId),
|
||||
projectKind: emptyProject.projectKind,
|
||||
iconWorkingDir: emptyProject.projectRootPath,
|
||||
workspaceKeys: [],
|
||||
workspaces: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const workspace of workspaceList) {
|
||||
const project =
|
||||
byProject.get(workspace.projectId) ??
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("runCreateEmptyWorkspace", () => {
|
||||
cwd: "/sample/repo",
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateEmptyWorkspaceInput {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
navigate: (serverId: string, workspaceId: string) => void;
|
||||
@@ -23,6 +24,7 @@ export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput):
|
||||
cwd: payload.cwd,
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
navigate(serverId, ensuredWorkspace.id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
@@ -101,6 +102,10 @@ interface NewWorkspaceProjectPickerInput {
|
||||
sourceDirectory?: string;
|
||||
projectId?: string;
|
||||
displayName?: string;
|
||||
// When true (workspaceMultiplicity), every project is selectable because a
|
||||
// local-backed workspace works for any directory, git or not. When false the
|
||||
// picker stays limited to worktree-capable projects (legacy behavior).
|
||||
allowAllProjects: boolean;
|
||||
}
|
||||
|
||||
interface NewWorkspaceProjectPickerState {
|
||||
@@ -118,6 +123,10 @@ interface NewWorkspaceProjectPickerState {
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const PROJECT_OPTION_PREFIX = "project:";
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
|
||||
// this so toggling Isolation to Local hides the row without shifting the form.
|
||||
const BADGE_HEIGHT = 28;
|
||||
|
||||
function RefPickerBadgeContent({
|
||||
selectedItem,
|
||||
@@ -238,9 +247,9 @@ function ProjectPickerTrigger({
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
imageStyle={styles.badgeProjectIcon}
|
||||
fallbackStyle={styles.badgeProjectIconFallback}
|
||||
textStyle={styles.badgeProjectIconFallbackText}
|
||||
imageStyle={styles.projectIcon}
|
||||
fallbackStyle={styles.projectIconFallback}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
) : (
|
||||
<Folder size={iconSize} color={iconColor} />
|
||||
@@ -352,6 +361,50 @@ function PickerOptionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function BackingOptionItem({
|
||||
optionId,
|
||||
label,
|
||||
selected,
|
||||
active,
|
||||
disabled,
|
||||
onPress,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
optionId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
const leadingSlot = useMemo(
|
||||
() => (
|
||||
<View style={styles.rowIconBox}>
|
||||
{optionId === "worktree" ? (
|
||||
<GitBranch size={iconSize} color={iconColor} />
|
||||
) : (
|
||||
<Folder size={iconSize} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[optionId, iconSize, iconColor],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`workspace-create-backing-${optionId}`}
|
||||
label={label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectOptionItem({
|
||||
testID,
|
||||
projectKey,
|
||||
@@ -382,9 +435,9 @@ function ProjectOptionItem({
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
imageStyle={styles.projectOptionIcon}
|
||||
fallbackStyle={styles.projectOptionIconFallback}
|
||||
textStyle={styles.projectOptionIconFallbackText}
|
||||
imageStyle={styles.projectIcon}
|
||||
fallbackStyle={styles.projectIconFallback}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
@@ -477,6 +530,7 @@ function useNewWorkspaceProjectPicker({
|
||||
sourceDirectory,
|
||||
projectId,
|
||||
displayName: displayNameProp,
|
||||
allowAllProjects,
|
||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
@@ -509,11 +563,21 @@ function useNewWorkspaceProjectPicker({
|
||||
}),
|
||||
[lastActiveProject, projects, routeProject],
|
||||
);
|
||||
const worktreeProjects = useMemo(
|
||||
() => projects.filter((project) => project.canCreateWorktree),
|
||||
[projects],
|
||||
const selectableProjects = useMemo(
|
||||
() => (allowAllProjects ? projects : projects.filter((project) => project.canCreateWorktree)),
|
||||
[allowAllProjects, projects],
|
||||
);
|
||||
|
||||
// expo-router reuses the 'new' screen across navigations without remounting, so
|
||||
// a manual picker choice would otherwise stick when navigating to a different
|
||||
// project's New Workspace. Resetting on route project identity lets each
|
||||
// route-driven navigation preselect its own project; in-screen manual override
|
||||
// still works within a single visit.
|
||||
const routeProjectKey = routeProject?.projectKey ?? null;
|
||||
useEffect(() => {
|
||||
setManualProjectKey(null);
|
||||
}, [routeProjectKey]);
|
||||
|
||||
const selectedProjectKey = manualProjectKey ?? initialProject?.projectKey ?? null;
|
||||
|
||||
const selectedProject = useMemo(
|
||||
@@ -527,16 +591,17 @@ function useNewWorkspaceProjectPicker({
|
||||
[lastActiveProject, projects, routeProject, selectedProjectKey],
|
||||
);
|
||||
const { options: projectPickerOptions, projectByOptionId }: ProjectOptionData = useMemo(
|
||||
() => computeProjectOptionData(worktreeProjects),
|
||||
[worktreeProjects],
|
||||
() => computeProjectOptionData(selectableProjects),
|
||||
[selectableProjects],
|
||||
);
|
||||
const handleSelectProjectOption = useCallback(
|
||||
(id: string) => {
|
||||
const project = projectByOptionId.get(id);
|
||||
if (!project?.canCreateWorktree) return;
|
||||
if (!project) return;
|
||||
if (!allowAllProjects && !project.canCreateWorktree) return;
|
||||
setManualProjectKey(project.projectKey);
|
||||
},
|
||||
[projectByOptionId],
|
||||
[allowAllProjects, projectByOptionId],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -552,6 +617,103 @@ function useNewWorkspaceProjectPicker({
|
||||
};
|
||||
}
|
||||
|
||||
function IsolationPickerTrigger({
|
||||
pickerAnchorRef,
|
||||
onPress,
|
||||
disabled,
|
||||
badgePressableStyle,
|
||||
backing,
|
||||
label,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
pickerAnchorRef: React.RefObject<View | null>;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
backing: "local" | "worktree";
|
||||
label: string;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
ref={pickerAnchorRef}
|
||||
testID="workspace-create-backing-trigger"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace isolation"
|
||||
>
|
||||
<View style={styles.badgeIconBox}>
|
||||
{backing === "worktree" ? (
|
||||
<GitBranch size={iconSize} color={iconColor} />
|
||||
) : (
|
||||
<Folder size={iconSize} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<ChevronDown size={iconSize} color={iconColor} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Each row aligns its label glyph with the screen heading glyph and lets the
|
||||
// label's natural width push the control. The label and control sit side by
|
||||
// side with no held horizontal space.
|
||||
function LabeledRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceBackingState {
|
||||
backing: "local" | "worktree";
|
||||
setBacking: (value: "local" | "worktree") => void;
|
||||
effectiveBacking: "local" | "worktree";
|
||||
canCreateWorktree: boolean;
|
||||
showRefPicker: boolean;
|
||||
}
|
||||
|
||||
// Worktree backing only makes sense for a git checkout. The effective backing
|
||||
// falls back to local whenever the selected directory isn't git so the flow
|
||||
// never submits an impossible request.
|
||||
function useWorkspaceBacking(input: {
|
||||
supportsMultiplicity: boolean;
|
||||
selectedIsGit: boolean;
|
||||
}): WorkspaceBackingState {
|
||||
const { supportsMultiplicity, selectedIsGit } = input;
|
||||
const [backing, setBacking] = useState<"local" | "worktree">("local");
|
||||
const canCreateWorktree = supportsMultiplicity && selectedIsGit;
|
||||
const isWorktree = backing === "worktree" && canCreateWorktree;
|
||||
|
||||
useEffect(() => {
|
||||
if (backing === "worktree" && !canCreateWorktree) {
|
||||
setBacking("local");
|
||||
}
|
||||
}, [backing, canCreateWorktree]);
|
||||
|
||||
return {
|
||||
backing,
|
||||
setBacking,
|
||||
effectiveBacking: isWorktree ? "worktree" : "local",
|
||||
canCreateWorktree,
|
||||
showRefPicker: !supportsMultiplicity || isWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
function backingLabel(t: TFunction, backing: "local" | "worktree"): string {
|
||||
return backing === "worktree"
|
||||
? t("newWorkspace.backing.worktree")
|
||||
: t("newWorkspace.backing.local");
|
||||
}
|
||||
|
||||
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
||||
if (input.isCompact) {
|
||||
return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }];
|
||||
@@ -610,6 +772,42 @@ async function createAndMergeWorkspace(input: {
|
||||
return normalizedWorkspace;
|
||||
}
|
||||
|
||||
async function createMultiplicityWorkspace(input: {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
backing: "local" | "worktree";
|
||||
project: HostProjectListItem;
|
||||
selectedItem: PickerItem | null;
|
||||
currentBranch: string | null;
|
||||
withInitialAgent: boolean;
|
||||
mergeWorkspaces: (
|
||||
serverId: string,
|
||||
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
|
||||
) => void;
|
||||
serverId: string;
|
||||
createFailedMessage: string;
|
||||
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
||||
const isWorktree = input.backing === "worktree";
|
||||
const baseBranch = isWorktree
|
||||
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
|
||||
: undefined;
|
||||
const payload = await input.client.createWorkspace({
|
||||
backing: input.backing,
|
||||
cwd: input.project.iconWorkingDir,
|
||||
projectId: input.project.projectKey,
|
||||
...(isWorktree ? { branch: createNameId() } : {}),
|
||||
...(baseBranch ? { baseBranch } : {}),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? input.createFailedMessage);
|
||||
}
|
||||
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
const workspaceForInitialMerge = input.withInitialAgent
|
||||
? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() }
|
||||
: normalizedWorkspace;
|
||||
input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]);
|
||||
return normalizedWorkspace;
|
||||
}
|
||||
|
||||
interface CreateChatAgentInput {
|
||||
payload: MessagePayload;
|
||||
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
||||
@@ -617,6 +815,7 @@ interface CreateChatAgentInput {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
@@ -641,6 +840,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
cwd,
|
||||
prompt: text,
|
||||
attachments: reviewAttachments,
|
||||
withInitialAgent: true,
|
||||
});
|
||||
submitWorkspaceDraft({
|
||||
serverId,
|
||||
@@ -776,6 +976,10 @@ export function NewWorkspaceScreen({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toast = useToast();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
const supportsWorkspaceMultiplicity = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceMultiplicity === true,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -784,10 +988,12 @@ export function NewWorkspaceScreen({
|
||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||
const [backingPickerOpen, setBackingPickerOpen] = useState(false);
|
||||
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
||||
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
|
||||
const pickerAnchorRef = useRef<View>(null);
|
||||
const projectPickerAnchorRef = useRef<View>(null);
|
||||
const backingPickerAnchorRef = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = pickerSearchQuery.trim();
|
||||
@@ -813,6 +1019,7 @@ export function NewWorkspaceScreen({
|
||||
sourceDirectory: sourceDirectoryProp,
|
||||
projectId,
|
||||
displayName: displayNameProp,
|
||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||
});
|
||||
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
|
||||
const draftKey = `new-workspace:${serverId}:${selectedSourceDirectory ?? "choose-project"}`;
|
||||
@@ -859,6 +1066,10 @@ export function NewWorkspaceScreen({
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||
const { effectiveBacking, setBacking, canCreateWorktree, showRefPicker } = useWorkspaceBacking({
|
||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||
selectedIsGit: checkoutStatusQuery.data?.isGit === true,
|
||||
});
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery],
|
||||
@@ -942,13 +1153,14 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const handleSelectProjectOption = useCallback(
|
||||
(id: string) => {
|
||||
const project = projectByOptionId.get(id);
|
||||
if (!project?.canCreateWorktree) return;
|
||||
// selectProjectOption enforces selectability (worktree-only when
|
||||
// multiplicity is off, any project when it's on); don't re-gate here on
|
||||
// canCreateWorktree or non-git projects become unselectable.
|
||||
selectProjectOption(id);
|
||||
setProjectPickerOpen(false);
|
||||
setManualPickerSelection(null);
|
||||
},
|
||||
[projectByOptionId, selectProjectOption],
|
||||
[selectProjectOption],
|
||||
);
|
||||
|
||||
const checkoutHintPrAttachment = useMemo(
|
||||
@@ -985,6 +1197,58 @@ export function NewWorkspaceScreen({
|
||||
setProjectPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const openBackingPicker = useCallback(() => {
|
||||
setBackingPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBackingPickerOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setBackingPickerOpen(nextOpen);
|
||||
}, []);
|
||||
|
||||
// "New worktree" is omitted entirely (not disabled) when the project isn't a
|
||||
// git checkout, since worktree backing is impossible there.
|
||||
const backingOptions = useMemo<ComboboxOptionType[]>(() => {
|
||||
const localOption = { id: "local", label: backingLabel(t, "local") };
|
||||
if (!canCreateWorktree) return [localOption];
|
||||
return [localOption, { id: "worktree", label: backingLabel(t, "worktree") }];
|
||||
}, [canCreateWorktree, t]);
|
||||
|
||||
const handleSelectBackingOption = useCallback(
|
||||
(id: string) => {
|
||||
setBacking(id === "worktree" ? "worktree" : "local");
|
||||
setBackingPickerOpen(false);
|
||||
},
|
||||
[setBacking],
|
||||
);
|
||||
|
||||
const renderBackingOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOptionType;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<BackingOptionItem
|
||||
optionId={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={isPending}
|
||||
onPress={onPress}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[isPending, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
const handleClearDraft = useCallback(() => {
|
||||
// No-op: screen navigates away on success, text should stay for retry on error
|
||||
}, []);
|
||||
@@ -1042,21 +1306,53 @@ export function NewWorkspaceScreen({
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
async (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => {
|
||||
async (input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
withInitialAgent: boolean;
|
||||
}) => {
|
||||
if (createdWorkspace) {
|
||||
return createdWorkspace;
|
||||
}
|
||||
const normalizedWorkspace = await createAndMergeWorkspace({
|
||||
client: withConnectedClient(),
|
||||
createInput: buildCreateWorktreeInput(input),
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
});
|
||||
if (!selectedProject) {
|
||||
throw new Error("Choose a project");
|
||||
}
|
||||
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
||||
? await createMultiplicityWorkspace({
|
||||
client: withConnectedClient(),
|
||||
backing: effectiveBacking,
|
||||
project: selectedProject,
|
||||
selectedItem,
|
||||
currentBranch,
|
||||
withInitialAgent: input.withInitialAgent,
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
})
|
||||
: await createAndMergeWorkspace({
|
||||
client: withConnectedClient(),
|
||||
createInput: buildCreateWorktreeInput(input),
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
});
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
},
|
||||
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, t, withConnectedClient],
|
||||
[
|
||||
buildCreateWorktreeInput,
|
||||
createdWorkspace,
|
||||
currentBranch,
|
||||
effectiveBacking,
|
||||
mergeWorkspaces,
|
||||
selectedItem,
|
||||
selectedProject,
|
||||
serverId,
|
||||
supportsWorkspaceMultiplicity,
|
||||
t,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmitNewWorkspace = useCallback(
|
||||
@@ -1173,12 +1469,12 @@ export function NewWorkspaceScreen({
|
||||
description={project.iconWorkingDir}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={isPending || !project.canCreateWorktree}
|
||||
disabled={isPending || (!supportsWorkspaceMultiplicity && !project.canCreateWorktree)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[isPending, projectByOptionId, projectIconDataByProjectKey],
|
||||
[isPending, projectByOptionId, projectIconDataByProjectKey, supportsWorkspaceMultiplicity],
|
||||
);
|
||||
|
||||
const contentStyle = useMemo(
|
||||
@@ -1211,69 +1507,161 @@ export function NewWorkspaceScreen({
|
||||
? t("newWorkspace.refPicker.searching")
|
||||
: t("newWorkspace.refPicker.noMatchingRefs");
|
||||
|
||||
const backingTriggerLabel = backingLabel(t, effectiveBacking);
|
||||
|
||||
const formStack = useMemo(
|
||||
() => (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
|
||||
<LabeledRow label={t("newWorkspace.fields.project")}>
|
||||
<View>
|
||||
<ProjectPickerTrigger
|
||||
pickerAnchorRef={projectPickerAnchorRef}
|
||||
onPress={openProjectPicker}
|
||||
disabled={isPending || projectPickerOptions.length === 0}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
label={projectTriggerLabel}
|
||||
projectKey={selectedProject?.projectKey ?? null}
|
||||
iconDataUri={
|
||||
selectedProject
|
||||
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
|
||||
: null
|
||||
}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={projectPickerOptions}
|
||||
value={selectedProjectOptionId}
|
||||
onSelect={handleSelectProjectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search projects"
|
||||
title="Project"
|
||||
open={projectPickerOpen}
|
||||
onOpenChange={handleProjectPickerOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={projectPickerAnchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={renderProjectOption}
|
||||
/>
|
||||
</View>
|
||||
</LabeledRow>
|
||||
{/* The Isolation row keeps its height for non-git projects so switching
|
||||
projects never shifts the form; worktree backing is git-only, so a
|
||||
non-git project renders an invisible spacer matching the trigger
|
||||
height exactly. */}
|
||||
{canCreateWorktree ? (
|
||||
<LabeledRow label={t("newWorkspace.backing.label")}>
|
||||
<View>
|
||||
<IsolationPickerTrigger
|
||||
pickerAnchorRef={backingPickerAnchorRef}
|
||||
onPress={openBackingPicker}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
backing={effectiveBacking}
|
||||
label={backingTriggerLabel}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={backingOptions}
|
||||
value={effectiveBacking}
|
||||
onSelect={handleSelectBackingOption}
|
||||
title={t("newWorkspace.backing.label")}
|
||||
open={backingPickerOpen}
|
||||
onOpenChange={handleBackingPickerOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={backingPickerAnchorRef}
|
||||
renderOption={renderBackingOption}
|
||||
/>
|
||||
</View>
|
||||
</LabeledRow>
|
||||
) : (
|
||||
<View style={styles.baseSpacer} />
|
||||
)}
|
||||
{/* The Base row keeps its height so toggling Isolation never shifts the
|
||||
form; on Local backing it renders an invisible spacer with no label
|
||||
or control, matching the trigger height exactly. */}
|
||||
{showRefPicker ? (
|
||||
<LabeledRow label={t("newWorkspace.fields.base")}>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending || !selectedSourceDirectory}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</LabeledRow>
|
||||
) : (
|
||||
<View style={styles.baseSpacer} />
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[
|
||||
backingOptions,
|
||||
backingPickerOpen,
|
||||
backingTriggerLabel,
|
||||
badgePressableStyle,
|
||||
canCreateWorktree,
|
||||
effectiveBacking,
|
||||
handleBackingPickerOpenChange,
|
||||
handlePickerOpenChange,
|
||||
handleProjectPickerOpenChange,
|
||||
handleSelectBackingOption,
|
||||
handleSelectOption,
|
||||
handleSelectProjectOption,
|
||||
isPending,
|
||||
openBackingPicker,
|
||||
openPicker,
|
||||
openProjectPicker,
|
||||
options,
|
||||
pickerEmptyText,
|
||||
pickerOpen,
|
||||
projectPickerOpen,
|
||||
projectPickerOptions,
|
||||
projectTriggerLabel,
|
||||
projectIconDataByProjectKey,
|
||||
renderBackingOption,
|
||||
renderPickerOption,
|
||||
renderProjectOption,
|
||||
selectedItem,
|
||||
selectedOptionId,
|
||||
selectedProject,
|
||||
selectedProjectOptionId,
|
||||
selectedSourceDirectory,
|
||||
setPickerSearchQuery,
|
||||
showRefPicker,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
triggerLabel,
|
||||
],
|
||||
);
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() => (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.optionsRow}>
|
||||
<View>
|
||||
<ProjectPickerTrigger
|
||||
pickerAnchorRef={projectPickerAnchorRef}
|
||||
onPress={openProjectPicker}
|
||||
disabled={isPending || projectPickerOptions.length === 0}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
label={projectTriggerLabel}
|
||||
projectKey={selectedProject?.projectKey ?? null}
|
||||
iconDataUri={
|
||||
selectedProject
|
||||
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
|
||||
: null
|
||||
}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={projectPickerOptions}
|
||||
value={selectedProjectOptionId}
|
||||
onSelect={handleSelectProjectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search projects"
|
||||
title="Project"
|
||||
open={projectPickerOpen}
|
||||
onOpenChange={handleProjectPickerOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={projectPickerAnchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={renderProjectOption}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending || !selectedSourceDirectory}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
<>
|
||||
{agentControlsWithDisabled ? (
|
||||
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
||||
) : null}
|
||||
@@ -1294,40 +1682,16 @@ export function NewWorkspaceScreen({
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
),
|
||||
[
|
||||
acceptCheckoutHint,
|
||||
badgePressableStyle,
|
||||
agentControlsWithDisabled,
|
||||
checkoutHintPrAttachment,
|
||||
dismissCheckoutHint,
|
||||
handlePickerOpenChange,
|
||||
handleProjectPickerOpenChange,
|
||||
handleSelectOption,
|
||||
handleSelectProjectOption,
|
||||
isPending,
|
||||
openPicker,
|
||||
openProjectPicker,
|
||||
options,
|
||||
pickerEmptyText,
|
||||
pickerOpen,
|
||||
projectPickerOpen,
|
||||
projectPickerOptions,
|
||||
projectTriggerLabel,
|
||||
projectIconDataByProjectKey,
|
||||
renderPickerOption,
|
||||
renderProjectOption,
|
||||
selectedItem,
|
||||
selectedOptionId,
|
||||
selectedProject,
|
||||
selectedProjectOptionId,
|
||||
selectedSourceDirectory,
|
||||
setPickerSearchQuery,
|
||||
agentControlsWithDisabled,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
triggerLabel,
|
||||
],
|
||||
);
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
@@ -1342,6 +1706,7 @@ export function NewWorkspaceScreen({
|
||||
<View style={styles.composerTitleContainer}>
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
<Composer
|
||||
agentId={draftKey}
|
||||
serverId={serverId}
|
||||
@@ -1349,6 +1714,7 @@ export function NewWorkspaceScreen({
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
@@ -1410,16 +1776,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionsRow: {
|
||||
formStack: {
|
||||
marginBottom: theme.spacing[8],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// The row's left inset matches the heading's text x (composerTitleContainer
|
||||
// paddingLeft) so each label glyph aligns with the "New workspace" glyph. The
|
||||
// label keeps its natural width and the control sits immediately beside it.
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
baseSpacer: {
|
||||
height: BADGE_HEIGHT,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 28,
|
||||
height: BADGE_HEIGHT,
|
||||
maxWidth: 240,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
@@ -1428,7 +1808,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
checkoutHintBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 28,
|
||||
height: BADGE_HEIGHT,
|
||||
maxWidth: 240,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
@@ -1467,20 +1847,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
badgeProjectIcon: {
|
||||
projectIcon: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
badgeProjectIconFallback: {
|
||||
projectIconFallback: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
badgeProjectIconFallbackText: {
|
||||
fontSize: 10,
|
||||
projectIconFallbackText: {
|
||||
// Single uppercase initial inside an iconSize.md (16px) square — below the
|
||||
// smallest font-size token, so it stays a literal sized to the box.
|
||||
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
},
|
||||
rowIconBox: {
|
||||
@@ -1489,20 +1871,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectOptionIcon: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectOptionIconFallback: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectOptionIconFallbackText: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,8 +7,12 @@ export type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type TerminalEntry = ListTerminalsPayload["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
|
||||
return ["terminals", serverId, workspaceDirectory] as const;
|
||||
export function buildTerminalsQueryKey(
|
||||
serverId: string,
|
||||
workspaceDirectory: string | null,
|
||||
workspaceId?: string | null,
|
||||
) {
|
||||
return ["terminals", serverId, workspaceDirectory, workspaceId ?? null] as const;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceTerminal(input: {
|
||||
|
||||
@@ -73,8 +73,9 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
() =>
|
||||
buildTerminalsQueryKey(normalizedServerId, workspaceDirectory, normalizedWorkspaceId || null),
|
||||
[normalizedServerId, normalizedWorkspaceId, workspaceDirectory],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -84,7 +85,9 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
return await client.listTerminals(workspaceDirectory, undefined, {
|
||||
workspaceId: normalizedWorkspaceId || undefined,
|
||||
});
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
@@ -125,8 +128,11 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, {
|
||||
command: _input.profile.command,
|
||||
args: _input.profile.args,
|
||||
workspaceId: normalizedWorkspaceId || undefined,
|
||||
})
|
||||
: await client.createTerminal(workspaceDirectory);
|
||||
: await client.createTerminal(workspaceDirectory, undefined, undefined, {
|
||||
workspaceId: normalizedWorkspaceId || undefined,
|
||||
});
|
||||
// The daemon reports a failed spawn (e.g. a profile command that isn't
|
||||
// installed) via payload.error with a null terminal. Surface it instead
|
||||
// of silently treating the create as a no-op success.
|
||||
@@ -177,25 +183,47 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paneWorkspaceId = normalizedWorkspaceId || undefined;
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Two workspaces can share a cwd, so the push can carry terminals from a
|
||||
// sibling workspace. Keep only the ones whose workspaceId matches this
|
||||
// pane; terminals without a workspaceId predate Model B and belong to
|
||||
// whichever pane is watching the cwd.
|
||||
const matchingTerminals = message.payload.terminals.filter(
|
||||
(terminal) =>
|
||||
terminal.workspaceId === undefined || terminal.workspaceId === paneWorkspaceId,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
terminals: matchingTerminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
client.subscribeTerminals({
|
||||
cwd: workspaceDirectory,
|
||||
workspaceId: paneWorkspaceId,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory, workspaceId: paneWorkspaceId });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedWorkspaceId,
|
||||
queryClient,
|
||||
queryKey,
|
||||
workspaceDirectory,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCreateInput) {
|
||||
|
||||
@@ -42,7 +42,7 @@ import invariant from "tiny-invariant";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -1183,7 +1183,6 @@ interface WorkspaceHeaderTitleBarProps {
|
||||
subtitle: string;
|
||||
showSubtitle: boolean;
|
||||
currentBranchName: string | null;
|
||||
isGitCheckout: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
@@ -1219,7 +1218,6 @@ function WorkspaceHeaderTitleBar({
|
||||
subtitle,
|
||||
showSubtitle,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceScripts,
|
||||
@@ -1256,13 +1254,7 @@ function WorkspaceHeaderTitleBar({
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
title={title}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isGitCheckout={isGitCheckout}
|
||||
/>
|
||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||
{showSubtitle ? (
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
@@ -1798,6 +1790,7 @@ function WorkspaceScreenContent({
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -3637,7 +3630,6 @@ function WorkspaceScreenContent({
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
workspaceScripts={workspaceScripts}
|
||||
|
||||
@@ -47,6 +47,45 @@ describe("workspace source of truth consumption", () => {
|
||||
expect(sidebarWorkspace.statusBucket).toBe("running");
|
||||
});
|
||||
|
||||
it("maps the sidebar entry branch from gitRuntime.currentBranch", () => {
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
serverId: "srv",
|
||||
workspace: createWorkspaceDescriptor({
|
||||
name: "feat/workspace-sot",
|
||||
gitRuntime: {
|
||||
currentBranch: "feat/real-branch",
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(entry.currentBranch).toBe("feat/real-branch");
|
||||
});
|
||||
|
||||
it("normalizes detached HEAD, blank, and missing branches to null", () => {
|
||||
const detached = createSidebarWorkspaceEntry({
|
||||
serverId: "srv",
|
||||
workspace: createWorkspaceDescriptor({
|
||||
gitRuntime: { currentBranch: "HEAD", isDirty: false, aheadOfOrigin: 0 },
|
||||
}),
|
||||
});
|
||||
const blank = createSidebarWorkspaceEntry({
|
||||
serverId: "srv",
|
||||
workspace: createWorkspaceDescriptor({
|
||||
gitRuntime: { currentBranch: " ", isDirty: false, aheadOfOrigin: 0 },
|
||||
}),
|
||||
});
|
||||
const missing = createSidebarWorkspaceEntry({
|
||||
serverId: "srv",
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
});
|
||||
|
||||
expect(detached.currentBranch).toBeNull();
|
||||
expect(blank.currentBranch).toBeNull();
|
||||
expect(missing.currentBranch).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the header skeleton while the workspace descriptor is missing", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
|
||||
@@ -232,6 +232,29 @@ describe("workspace structure composition", () => {
|
||||
tracked.stop();
|
||||
});
|
||||
|
||||
it("renders a project with zero active workspaces as an empty project parent", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
||||
{
|
||||
projectId: "empty-project",
|
||||
projectDisplayName: "Empty Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
|
||||
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), SERVER_ID);
|
||||
expect(projects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectKey: "empty-project",
|
||||
projectName: "Empty Project",
|
||||
workspaceKeys: [],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("changes when a structure-relevant project identity field changes", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "workspace-a",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { WorkspaceDescriptor } from "../session-store";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
@@ -17,7 +17,11 @@ export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/w
|
||||
export interface SessionsSnapshot {
|
||||
sessions: Record<
|
||||
string,
|
||||
{ hasHydratedWorkspaces?: boolean; workspaces: Map<string, WorkspaceDescriptor> }
|
||||
{
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects?: Map<string, EmptyProjectDescriptor>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -142,12 +146,18 @@ export function selectWorkspaceStructureProjects(
|
||||
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||
}
|
||||
|
||||
const workspaces = state.sessions[serverId]?.workspaces;
|
||||
if (!workspaces || workspaces.size === 0) {
|
||||
const session = state.sessions[serverId];
|
||||
const workspaces = session?.workspaces;
|
||||
const emptyProjects = session?.emptyProjects;
|
||||
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
|
||||
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||
}
|
||||
|
||||
return buildWorkspaceStructureProjects({ serverId, workspaces: workspaces.values() });
|
||||
return buildWorkspaceStructureProjects({
|
||||
serverId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
emptyProjects: emptyProjects?.values() ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] {
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
ProjectPlacementPayload,
|
||||
ServerCapabilities,
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeWorkspaceOpaqueId,
|
||||
@@ -106,6 +107,7 @@ export interface Agent {
|
||||
lastError?: string | null;
|
||||
title: string | null;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
model: string | null;
|
||||
features?: AgentFeature[];
|
||||
thinkingOptionId?: string | null;
|
||||
@@ -128,6 +130,7 @@ export interface WorkspaceDescriptor {
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||
name: string;
|
||||
title?: string | null;
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
statusEnteredAt: Date | null;
|
||||
archivingAt: string | null;
|
||||
@@ -159,6 +162,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
name: payload.name,
|
||||
title: payload.title ?? null,
|
||||
status: payload.status,
|
||||
statusEnteredAt,
|
||||
archivingAt: payload.archivingAt ?? null,
|
||||
@@ -170,6 +174,26 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmptyProjectDescriptor {
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
}
|
||||
|
||||
export function normalizeEmptyProjectDescriptor(
|
||||
payload: WorkspaceProjectDescriptorPayload,
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function preserveWorkspaceDescriptorIdentity(
|
||||
incoming: WorkspaceDescriptor,
|
||||
existing?: WorkspaceDescriptor | null,
|
||||
@@ -305,6 +329,9 @@ export interface SessionState {
|
||||
agents: Map<string, Agent>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
||||
// empty project rows in the sidebar.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -426,6 +453,8 @@ interface SessionStoreActions {
|
||||
) => void;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
@@ -497,6 +526,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agents: new Map(),
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1178,6 +1208,51 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setEmptyProjects: (serverId, emptyProjects) => {
|
||||
const next = new Map<string, EmptyProjectDescriptor>();
|
||||
for (const project of emptyProjects) {
|
||||
next.set(project.projectId, project);
|
||||
}
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.emptyProjects.size === 0 && next.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addEmptyProject: (serverId, emptyProject) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const existing = session.emptyProjects.get(emptyProject.projectId);
|
||||
if (existing && equal(existing, emptyProject)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.emptyProjects);
|
||||
next.set(emptyProject.projectId, emptyProject);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1187,7 +1262,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
let changed = false;
|
||||
// A workspace landing in a project means that project is no longer
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1203,7 +1285,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, workspaces: next },
|
||||
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
lastError: snapshot.lastError ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceId: snapshot.workspaceId,
|
||||
model: snapshot.model ?? null,
|
||||
features: snapshot.features,
|
||||
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
isSidebarProjectFlattened,
|
||||
} from "./sidebar-project-row-model";
|
||||
import { buildSidebarProjectRowModel } from "./sidebar-project-row-model";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
@@ -18,6 +15,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "paseo",
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
@@ -45,62 +44,27 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
|
||||
}
|
||||
|
||||
describe("buildSidebarProjectRowModel", () => {
|
||||
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: "ws-non-git",
|
||||
workspaceKind: "checkout",
|
||||
statusBucket: "running",
|
||||
});
|
||||
|
||||
it("renders a non-git single-workspace project as an expandable section", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
workspaces: [workspace({ workspaceId: "ws-non-git", workspaceKind: "checkout" })],
|
||||
}),
|
||||
collapsed: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
chevron: null,
|
||||
kind: "project_section",
|
||||
chevron: "collapse",
|
||||
trailingAction: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds flattened non-git rows without route selection input", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
serverId: "srv-2",
|
||||
workspaceId: "ws-non-git",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
chevron: null,
|
||||
trailingAction: "none",
|
||||
});
|
||||
expect(result).not.toHaveProperty("selected");
|
||||
});
|
||||
|
||||
it("keeps single-workspace git projects as sections with the new worktree action", () => {
|
||||
const onlyWorkspace = workspace({
|
||||
workspaceId: "ws-main",
|
||||
workspaceKind: "checkout",
|
||||
});
|
||||
|
||||
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [onlyWorkspace],
|
||||
workspaces: [workspace({ workspaceId: "ws-main", workspaceKind: "checkout" })],
|
||||
}),
|
||||
collapsed: true,
|
||||
});
|
||||
@@ -112,7 +76,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps multi-workspace git projects as expandable sections with a new worktree action", () => {
|
||||
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
@@ -130,28 +94,17 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true only for single-workspace non-git projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
});
|
||||
it("renders an empty project as an expandable section", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "git", workspaces: [] }),
|
||||
collapsed: false,
|
||||
});
|
||||
|
||||
it("returns false for multi-workspace projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(
|
||||
project({
|
||||
workspaces: [
|
||||
workspace({ workspaceId: "ws-main" }),
|
||||
workspace({ workspaceId: "ws-feat" }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(result).toEqual({
|
||||
kind: "project_section",
|
||||
chevron: "collapse",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,20 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
export interface SidebarProjectWorkspaceLinkRowModel {
|
||||
kind: "workspace_link";
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
chevron: null;
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
kind: "project_section";
|
||||
chevron: "expand" | "collapse" | null;
|
||||
chevron: "expand" | "collapse";
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
|
||||
export type SidebarProjectRowModel =
|
||||
| SidebarProjectWorkspaceLinkRowModel
|
||||
| SidebarProjectSectionRowModel;
|
||||
|
||||
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
|
||||
return project.workspaces.length === 1 && project.projectKind !== "git";
|
||||
}
|
||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
}): SidebarProjectRowModel {
|
||||
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
|
||||
? (input.project.workspaces[0] ?? null)
|
||||
: null;
|
||||
|
||||
if (flattenedWorkspace) {
|
||||
return {
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
chevron: null,
|
||||
trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
const collapsible = input.project.projectKind === "git" || input.project.workspaces.length > 1;
|
||||
|
||||
let chevron: "expand" | "collapse" | null;
|
||||
if (!collapsible) chevron = null;
|
||||
else if (input.collapsed) chevron = "expand";
|
||||
else chevron = "collapse";
|
||||
|
||||
return {
|
||||
kind: "project_section",
|
||||
chevron,
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ function workspace(input: {
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: input.name,
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket: input.statusBucket ?? "done",
|
||||
archivingAt: null,
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
@@ -119,21 +121,29 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
|
||||
});
|
||||
|
||||
it("still excludes collapsed single-workspace git projects because they are not flattened", () => {
|
||||
const projects = [
|
||||
project("p1", [
|
||||
workspace({
|
||||
serverId: "s1",
|
||||
workspaceId: "ws-main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
name: "main",
|
||||
}),
|
||||
]),
|
||||
];
|
||||
it("excludes a collapsed project's workspaces regardless of project kind", () => {
|
||||
const gitProject = project("p1", [
|
||||
workspace({
|
||||
serverId: "s1",
|
||||
workspaceId: "ws-main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
name: "main",
|
||||
}),
|
||||
]);
|
||||
const directoryProject = project("p2", [
|
||||
workspace({
|
||||
serverId: "s1",
|
||||
workspaceId: "ws-script",
|
||||
workspaceDirectory: "/scripts",
|
||||
name: "scripts",
|
||||
}),
|
||||
]);
|
||||
directoryProject.projectKind = "directory";
|
||||
directoryProject.canCreateWorktree = false;
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
projects: [gitProject, directoryProject],
|
||||
collapsedProjectKeys: new Set<string>(["p1", "p2"]),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toEqual([]);
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string;
|
||||
@@ -32,7 +31,7 @@ export function buildSidebarShortcutModel(input: {
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
||||
|
||||
for (const project of input.projects) {
|
||||
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
parentAgentId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
@@ -44,6 +45,7 @@ function makeAgent(input: {
|
||||
},
|
||||
title: null,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
@@ -267,6 +269,65 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
});
|
||||
|
||||
it("matches agents by workspaceId regardless of cwd", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
"stamped-agent",
|
||||
makeAgent({
|
||||
id: "stamped-agent",
|
||||
cwd: "/repo/subdir",
|
||||
workspaceId: "ws-1",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"]));
|
||||
});
|
||||
|
||||
it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
"other-ws-agent",
|
||||
makeAgent({
|
||||
id: "other-ws-agent",
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: "ws-2",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.knownAgentIds).toEqual(new Set<string>());
|
||||
});
|
||||
|
||||
it("falls back to cwd matching for legacy agents without a workspaceId", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"]));
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
const agentVisibility = {
|
||||
activeAgentIds: new Set(["active-agent"]),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
|
||||
return normalizeWorkspacePath(value) ?? "";
|
||||
@@ -13,14 +13,31 @@ export interface WorkspaceAgentVisibility {
|
||||
knownAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
function agentBelongsToWorkspace(input: {
|
||||
agent: Agent;
|
||||
workspaceId: string | null;
|
||||
normalizedWorkspaceDirectory: string;
|
||||
}): boolean {
|
||||
const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId);
|
||||
if (agentWorkspaceId) {
|
||||
return input.workspaceId !== null && agentWorkspaceId === input.workspaceId;
|
||||
}
|
||||
// COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall
|
||||
// back to the single approved cwd→id inference by comparing the agent's cwd to
|
||||
// this workspace's directory. Drop when the daemon floor always stamps workspaceId.
|
||||
return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails?: Map<string, Agent> | undefined;
|
||||
workspaceId?: string | null | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||
if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
@@ -32,7 +49,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
@@ -44,7 +61,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
// A workspace is the last reference to its backing worktree when no other active
|
||||
// workspace on the same host points at the same directory. A directory can back
|
||||
// multiple workspaces (Model B), so a sibling reference must keep the worktree on
|
||||
// disk even when this workspace is archived. Local checkouts are never worktrees,
|
||||
// so they never reach the disk-deletion prompt.
|
||||
export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): boolean {
|
||||
return useSessionStore((state) => {
|
||||
if (workspace.workspaceKind !== "worktree") {
|
||||
return false;
|
||||
}
|
||||
const directory = normalizeWorkspacePath(workspace.workspaceDirectory);
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const workspaces = state.sessions[workspace.serverId]?.workspaces;
|
||||
if (!workspaces) {
|
||||
return true;
|
||||
}
|
||||
for (const candidate of workspaces.values()) {
|
||||
if (candidate.id === workspace.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export interface WorkspaceArchiveController {
|
||||
// Begins the archive flow. For a last-reference worktree this opens the inline
|
||||
// keep/delete prompt; otherwise it archives the workspace record directly.
|
||||
beginArchive: () => void;
|
||||
// Inline prompt state for the last-reference worktree case.
|
||||
deletePromptOpen: boolean;
|
||||
confirmKeepOnDisk: () => void;
|
||||
confirmDeleteFromDisk: () => void;
|
||||
cancelDeletePrompt: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceArchive(input: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
onArchiveStarted: () => void;
|
||||
onSetHiding?: (hiding: boolean) => void;
|
||||
}): WorkspaceArchiveController {
|
||||
const { workspace, onArchiveStarted, onSetHiding } = input;
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const isLastWorktreeReference = useIsLastWorktreeReference(workspace);
|
||||
const [deletePromptOpen, setDeletePromptOpen] = useState(false);
|
||||
|
||||
const archiveWorktreeRecord = useCallback(
|
||||
(deleteWorktreeFromDisk: boolean) => {
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onArchiveStarted();
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
workspaceId: workspace.workspaceId,
|
||||
deleteWorktreeFromDisk,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
);
|
||||
});
|
||||
},
|
||||
[archiveWorktree, onArchiveStarted, t, toast, workspace],
|
||||
);
|
||||
|
||||
const archiveNonWorktreeRecord = useCallback(async () => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return;
|
||||
}
|
||||
onSetHiding?.(true);
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: onArchiveStarted,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
onSetHiding?.(false);
|
||||
}
|
||||
}, [onArchiveStarted, onSetHiding, t, toast, workspace]);
|
||||
|
||||
const beginArchive = useCallback(() => {
|
||||
void (async () => {
|
||||
if (workspace.workspaceKind === "worktree") {
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
if (isLastWorktreeReference) {
|
||||
setDeletePromptOpen(true);
|
||||
return;
|
||||
}
|
||||
archiveWorktreeRecord(false);
|
||||
return;
|
||||
}
|
||||
await archiveNonWorktreeRecord();
|
||||
})();
|
||||
}, [archiveNonWorktreeRecord, archiveWorktreeRecord, isLastWorktreeReference, workspace]);
|
||||
|
||||
const confirmKeepOnDisk = useCallback(() => {
|
||||
setDeletePromptOpen(false);
|
||||
archiveWorktreeRecord(false);
|
||||
}, [archiveWorktreeRecord]);
|
||||
|
||||
const confirmDeleteFromDisk = useCallback(() => {
|
||||
setDeletePromptOpen(false);
|
||||
archiveWorktreeRecord(true);
|
||||
}, [archiveWorktreeRecord]);
|
||||
|
||||
const cancelDeletePrompt = useCallback(() => {
|
||||
setDeletePromptOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
beginArchive,
|
||||
deletePromptOpen,
|
||||
confirmKeepOnDisk,
|
||||
confirmDeleteFromDisk,
|
||||
cancelDeletePrompt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Inline keep/delete confirmation shown when archiving the last reference to a
|
||||
// Paseo-owned worktree. "Keep on disk" is the default, non-destructive choice;
|
||||
// "Delete" removes the worktree directory from disk.
|
||||
export function WorktreeDeletePrompt({
|
||||
visible,
|
||||
workspaceName,
|
||||
onKeep,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
workspaceName: string;
|
||||
onKeep: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo(
|
||||
() => ({ title: t("sidebar.workspace.confirmations.deleteWorktreePrompt.title") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onCancel}
|
||||
scrollable={false}
|
||||
desktopMaxWidth={420}
|
||||
testID="worktree-delete-confirm"
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.message}>
|
||||
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", {
|
||||
workspaceName,
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<Button variant="default" onPress={onKeep} testID="worktree-delete-confirm-keep">
|
||||
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.keep")}
|
||||
</Button>
|
||||
<Button variant="destructive" onPress={onDelete} testID="worktree-delete-confirm-delete">
|
||||
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.delete")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user