Make workspace IDs opaque, independent of the filesystem path (#1503)
* Make workspace IDs opaque, independent of the filesystem path Workspace IDs were the resolved checkout/worktree path, so code could treat an ID as a path: prefix matching, deriving directories from it, falling back to a path when a lookup missed. IDs are now opaque - compared only by exact equality and never used as a path. Anything path-shaped resolves the workspace record first and reads its cwd. New workspaces get a generated `wks_` ID; existing path-shaped IDs are read from disk and never regenerated, so there is no migration and no change to the wire or persisted schemas. Groundwork for running multiple workspaces in a single directory. * Keep attachment scope key stable and align refetch test The attachment scope key keeps `workspace=` instead of `wsid=` so existing persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch test no longer asserts a filter, matching refetch fetching one page and selecting by id client-side. * Fix live agent updates in directories without a registered workspace The opaque-ID work removed buildProjectPlacementForCwd's directory fallback, so an agent running in a folder with no registered workspace (e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate then threw "Workspace not found", the error was swallowed by its catch, and no agent_update was emitted — live model/thinking switches and status updates silently stopped. Caught by the live-preferences e2e suite. The fallback builds a directory-scoped project placement keyed by the path. That key is a project grouping key (non-git projects group by path), not a workspace id, so it stays within the opaque-id rule. * Always run server worktree archive, even without a resolved workspace Archiving bailed out entirely when the workspace was not found in the client store, so a race or stale state could make "archive" do nothing server-side with only a console.warn. The server archive is keyed by worktreePath, which is always available, so it now runs regardless; only the optimistic client-side updates (keyed by workspace id) are gated on the workspace being resolved. * Expect a directory-scoped placement for unregistered agent dirs This unit test asserted the no-placement behavior reverted in the live agent-update fix, which had broken live model/thinking switching. Update it to expect the directory-scoped placement now emitted for an agent in a directory with no registered workspace. * Fix opaque workspace routing in app E2E * Preserve workspace IDs during partial bootstrap * Fix archive flows for opaque workspace IDs * Stop treating opaque workspace IDs as filesystem paths Workspace IDs are opaque (wks_<hex>), but several call sites still passed the id where a directory was expected, which broke those flows for opaque IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git operations, and server archive/reconcile cleanup keyed git-watch and subscription teardown by id, leaking that state. Git and filesystem operations now take the workspace directory; the opaque id is used only for identity and cache keys. Archive/reconcile cleanup routes through a single teardownArchivedWorkspace helper that keeps the key split explicit: runtime store by id, git watch and subscription by cwd. Path-derived grouping keys are renamed to directory keys, and the helpers are split into workspace-identity and workspace-directory so the id-vs-path boundary is obvious.
This commit is contained in:
@@ -75,7 +75,7 @@ import {
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -276,7 +276,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||
workspaceDirectory: workspaceRoot,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -93,7 +93,7 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ function normalizeCwd(cwd: string): string {
|
||||
|
||||
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeScopePart(workspaceId)}`
|
||||
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
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 {
|
||||
@@ -35,11 +36,13 @@ export function BranchSwitcher({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
|
||||
@@ -120,10 +120,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
@@ -1538,7 +1535,7 @@ function WorkspaceRowWithMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -1580,7 +1577,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1660,7 +1657,7 @@ function WorkspaceRowWithMenu({
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceExecutionDirectory({
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1687,7 +1684,7 @@ function WorkspaceRowWithMenu({
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
@@ -40,10 +40,7 @@ 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 {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
@@ -355,7 +352,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -391,7 +388,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
if (!confirmed) return;
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -441,7 +438,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceExecutionDirectory({
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -462,7 +459,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
const targetCwd = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "@/composer/types";
|
||||
@@ -300,9 +300,10 @@ export function WorkspaceSetupDialog() {
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
||||
workspace: ensuredWorkspace,
|
||||
}).workspaceDirectory;
|
||||
const workspaceDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
});
|
||||
const agent = await connectedClient.createAgent(
|
||||
buildCreateAgentOptions({
|
||||
composerState,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -121,7 +121,7 @@ async function submitDraftCreateRequest(input: {
|
||||
attachments?: unknown;
|
||||
client: DaemonClient | null;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceExecutionAuthority: { workspaceId: string } | null;
|
||||
workspaceId: string | null;
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
@@ -141,13 +141,13 @@ async function submitDraftCreateRequest(input: {
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
workspaceExecutionAuthority,
|
||||
workspaceId,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
} = input;
|
||||
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
invariant(workspaceExecutionAuthority, "Workspace authority is required");
|
||||
invariant(workspaceId, "Workspace id is required");
|
||||
if (!client) {
|
||||
throw new Error(input.hostDisconnectedMessage);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function submitDraftCreateRequest(input: {
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await client.createAgent({
|
||||
config,
|
||||
workspaceId: workspaceExecutionAuthority.workspaceId,
|
||||
workspaceId,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
@@ -317,9 +317,8 @@ export function WorkspaceDraftAgentTab({
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const draftSetup = initialSetup ?? null;
|
||||
const draftWorkingDirectory = resolveDraftWorkingDirectory({
|
||||
workspaceDirectory,
|
||||
@@ -471,7 +470,7 @@ export function WorkspaceDraftAgentTab({
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceExecutionAuthority,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export function patchWorkspaceScripts(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
|
||||
@@ -37,7 +37,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.id,
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
@@ -51,13 +51,13 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
const workspaceId = "ws-feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -65,8 +65,7 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -301,9 +300,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(
|
||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
||||
@@ -313,6 +316,7 @@ describe("checkout-git-actions-store", () => {
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
@@ -325,49 +329,52 @@ describe("checkout-git-actions-store", () => {
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: "ws-feature",
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
// The server archive is keyed by worktreePath and must run regardless.
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
// The optimistic client-side mark is never keyed by the path.
|
||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual(
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
featureWorkspace,
|
||||
);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
||||
@@ -380,9 +387,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({ id: cwd, name: "feature" });
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -158,12 +158,13 @@ function snapshotWorktreeArchiveState(input: {
|
||||
worktreePath: string;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
}) ?? input.worktreePath;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
});
|
||||
const workspaceKey = workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
@@ -173,13 +174,13 @@ function snapshotWorktreeArchiveState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void {
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().removeWorkspace(serverId, worktreePath);
|
||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
||||
}
|
||||
|
||||
function restoreWorktreeArchiveState(input: {
|
||||
@@ -195,9 +196,9 @@ function restoreWorktreeArchiveState(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.worktreePath.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
@@ -504,31 +505,35 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
|
||||
});
|
||||
// The server archive is keyed by worktreePath and must always run. The
|
||||
// optimistic client-side updates are keyed by workspace id, so they only
|
||||
// apply when the workspace is resolved in the local store.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({
|
||||
serverId,
|
||||
worktreePath: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
if (workspace) {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
||||
if (workspace) {
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createBranchSwitcherOperations } from "./branch-switcher-operations";
|
||||
|
||||
function createRecordingClient() {
|
||||
const cwds: string[] = [];
|
||||
const client = {
|
||||
getBranchSuggestions: async (options: { cwd: string; limit?: number }) => {
|
||||
cwds.push(options.cwd);
|
||||
return { branches: [], error: null };
|
||||
},
|
||||
stashList: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { entries: [] };
|
||||
},
|
||||
stashSave: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
stashPop: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
checkoutSwitchBranch: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { error: null };
|
||||
},
|
||||
} as unknown as DaemonClient;
|
||||
return { client, cwds };
|
||||
}
|
||||
|
||||
describe("createBranchSwitcherOperations", () => {
|
||||
it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => {
|
||||
const workspaceDirectory = "/Users/dev/project";
|
||||
const workspaceId = "wks_3f9a2b1c";
|
||||
const { client, cwds } = createRecordingClient();
|
||||
|
||||
const operations = createBranchSwitcherOperations(client, workspaceDirectory);
|
||||
await operations.getBranchSuggestions(200);
|
||||
await operations.listPaseoStashes();
|
||||
await operations.saveStash("main");
|
||||
await operations.popStash(0);
|
||||
await operations.switchBranch("feature");
|
||||
|
||||
expect(cwds).toEqual([
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
]);
|
||||
expect(cwds).not.toContain(workspaceId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
// Binds the branch switcher's git operations to a single workspace directory, so a
|
||||
// workspace id can never be passed where a cwd is expected. `cwd` is set once here;
|
||||
// callers choose the operation, never the directory.
|
||||
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) {
|
||||
return {
|
||||
getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }),
|
||||
listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }),
|
||||
saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }),
|
||||
popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex),
|
||||
switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch),
|
||||
};
|
||||
}
|
||||
|
||||
export type BranchSwitcherOperations = ReturnType<typeof createBranchSwitcherOperations>;
|
||||
@@ -15,8 +15,9 @@ 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 { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
@@ -495,18 +496,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedWorkspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
}) ?? worktreePath;
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
}) as Href,
|
||||
);
|
||||
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"));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
|
||||
function workspaceNameFromDirectory(directory: string): string {
|
||||
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
|
||||
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
||||
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
|
||||
}
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
export interface SidebarWorkspaceEntry {
|
||||
@@ -54,7 +60,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
client: DaemonClient | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
currentBranchName: string | null;
|
||||
isGitCheckout: boolean;
|
||||
isConnected: boolean;
|
||||
@@ -30,6 +32,7 @@ export function useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
@@ -39,22 +42,29 @@ export function useBranchSwitcher({
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Git operations are bound to the workspace directory; the opaque workspace id is
|
||||
// used only for query cache identity below, never as a cwd.
|
||||
const operations = useMemo(
|
||||
() =>
|
||||
client && workspaceDirectory
|
||||
? createBranchSwitcherOperations(client, workspaceDirectory)
|
||||
: null,
|
||||
[client, workspaceDirectory],
|
||||
);
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!operations) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
const payload = await client.getBranchSuggestions({
|
||||
cwd: normalizedWorkspaceId,
|
||||
limit: 200,
|
||||
});
|
||||
const payload = await operations.getBranchSuggestions(200);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.branches ?? [];
|
||||
},
|
||||
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
|
||||
enabled: isOpen && isGitCheckout && Boolean(operations) && isConnected,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
@@ -70,20 +80,21 @@ export function useBranchSwitcher({
|
||||
);
|
||||
|
||||
const invalidateStashAndCheckout = useCallback(async () => {
|
||||
if (!workspaceDirectory) return;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
||||
invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: normalizedServerId,
|
||||
cwd: normalizedWorkspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
}),
|
||||
]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
const maybeRestoreStashForBranch = useCallback(
|
||||
async (branchId: string) => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
try {
|
||||
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
|
||||
const stashPayload = await operations.listPaseoStashes();
|
||||
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
|
||||
if (!targetStash) return;
|
||||
const shouldRestore = await confirmDialog({
|
||||
@@ -93,7 +104,7 @@ export function useBranchSwitcher({
|
||||
cancelLabel: t("branchSwitcher.later"),
|
||||
});
|
||||
if (!shouldRestore) return;
|
||||
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
|
||||
const popPayload = await operations.popStash(targetStash.index);
|
||||
if (popPayload.error) {
|
||||
toast.error(popPayload.error.message);
|
||||
} else {
|
||||
@@ -104,12 +115,12 @@ export function useBranchSwitcher({
|
||||
// Non-critical — user can still restore on next branch switch
|
||||
}
|
||||
},
|
||||
[client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
|
||||
[operations, invalidateStashAndCheckout, toast, t],
|
||||
);
|
||||
|
||||
const stashAndSwitch = useCallback(
|
||||
async (branchId: string) => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
const shouldStash = await confirmDialog({
|
||||
title: t("branchSwitcher.uncommittedTitle"),
|
||||
message: t("branchSwitcher.uncommittedMessage"),
|
||||
@@ -119,15 +130,13 @@ export function useBranchSwitcher({
|
||||
if (!shouldStash) return;
|
||||
|
||||
try {
|
||||
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
|
||||
branch: currentBranchName ?? undefined,
|
||||
});
|
||||
const stashPayload = await operations.saveStash(currentBranchName ?? undefined);
|
||||
if (stashPayload.error) {
|
||||
toast.error(stashPayload.error.message);
|
||||
return;
|
||||
}
|
||||
await invalidateStashAndCheckout();
|
||||
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
const switchPayload = await operations.switchBranch(branchId);
|
||||
if (switchPayload.error) {
|
||||
toast.error(switchPayload.error.message);
|
||||
return;
|
||||
@@ -137,7 +146,7 @@ export function useBranchSwitcher({
|
||||
toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash"));
|
||||
}
|
||||
},
|
||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t],
|
||||
[operations, currentBranchName, invalidateStashAndCheckout, toast, t],
|
||||
);
|
||||
|
||||
const handleBranchSelect = useCallback(
|
||||
@@ -145,9 +154,9 @@ export function useBranchSwitcher({
|
||||
if (branchId === currentBranchName) return;
|
||||
|
||||
void (async () => {
|
||||
if (!client) return;
|
||||
if (!operations) return;
|
||||
try {
|
||||
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
const payload = await operations.switchBranch(branchId);
|
||||
if (payload.error) {
|
||||
// If the error is about uncommitted changes, offer the stash dialog
|
||||
if (payload.error.message.toLowerCase().includes("uncommitted")) {
|
||||
@@ -166,11 +175,10 @@ export function useBranchSwitcher({
|
||||
})();
|
||||
},
|
||||
[
|
||||
client,
|
||||
operations,
|
||||
currentBranchName,
|
||||
invalidateStashAndCheckout,
|
||||
maybeRestoreStashForBranch,
|
||||
normalizedWorkspaceId,
|
||||
stashAndSwitch,
|
||||
t,
|
||||
toast,
|
||||
|
||||
@@ -230,9 +230,7 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only");
|
||||
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
|
||||
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
|
||||
expect(en.panels.file.executionDirectoryMissing).toBe(
|
||||
"Workspace execution directory not found.",
|
||||
);
|
||||
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
|
||||
});
|
||||
|
||||
it("includes workspace Git and review keys for the Batch 4B migration", () => {
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export const ar: TranslationResources = {
|
||||
creatingAgent: "وكيل الخلق",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.",
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
loading: "جارٍ تحميل الملف...",
|
||||
noPreview: "لا تتوفر معاينة",
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
|
||||
@@ -1298,7 +1298,7 @@ export const en = {
|
||||
creatingAgent: "Creating agent",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Workspace execution directory not found.",
|
||||
directoryMissing: "Workspace directory not found.",
|
||||
loading: "Loading file...",
|
||||
noPreview: "No preview available",
|
||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||
|
||||
@@ -1327,7 +1327,7 @@ export const es: TranslationResources = {
|
||||
creatingAgent: "Agente creador",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.",
|
||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||
loading: "Cargando archivo...",
|
||||
noPreview: "No hay vista previa disponible",
|
||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||
|
||||
@@ -1330,7 +1330,7 @@ export const fr: TranslationResources = {
|
||||
creatingAgent: "Agent créateur",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.",
|
||||
directoryMissing: "Répertoire Workspace introuvable.",
|
||||
loading: "Chargement du fichier...",
|
||||
noPreview: "Aucun aperçu disponible",
|
||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||
|
||||
@@ -1319,7 +1319,7 @@ export const ru: TranslationResources = {
|
||||
creatingAgent: "Создание агента",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "Каталог выполнения Workspace не найден.",
|
||||
directoryMissing: "Каталог Workspace не найден.",
|
||||
loading: "Загрузка файла...",
|
||||
noPreview: "Предварительный просмотр недоступен",
|
||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||
|
||||
@@ -1275,7 +1275,7 @@ export const zhCN: TranslationResources = {
|
||||
creatingAgent: "正在创建 Agent",
|
||||
},
|
||||
file: {
|
||||
executionDirectoryMissing: "未找到 workspace 执行目录。",
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
loading: "正在加载文件...",
|
||||
noPreview: "没有可用预览",
|
||||
binaryPreviewUnavailable: "二进制预览不可用",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BrowserPane } from "@/components/browser-pane";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
function getBrowserLabel(input: { title: string; url: string }): string {
|
||||
const title = input.title.trim();
|
||||
@@ -55,8 +55,7 @@ function useBrowserPanelDescriptor(target: {
|
||||
function BrowserPanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const { focusPane, isInteractive } = usePaneFocus();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
invariant(target.kind === "browser", "BrowserPanel requires browser target");
|
||||
return (
|
||||
<BrowserPane
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
@@ -28,15 +28,12 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
function FilePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceDirectory = workspaceAuthority?.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.executionDirectoryMissing")}</Text>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
@@ -37,21 +37,15 @@ function useTerminalPanelDescriptor(
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const workspace = useWorkspace(context.serverId, context.workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(
|
||||
workspaceAuthority.ok
|
||||
? "Workspace execution directory not found"
|
||||
: workspaceAuthority.message,
|
||||
);
|
||||
throw new Error("Workspace directory not found");
|
||||
}
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
@@ -76,13 +70,9 @@ function useTerminalPanelDescriptor(
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const isGitCheckout = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspace.projectKind === "git"
|
||||
: false;
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspace?.projectKind === "git";
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const handleOpenFileExplorer = useCallback(() => {
|
||||
if (!workspaceDirectory) {
|
||||
@@ -102,11 +92,7 @@ function TerminalPanel() {
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>
|
||||
{workspaceAuthority.ok
|
||||
? "Workspace execution directory not found."
|
||||
: workspaceAuthority.message}
|
||||
</Text>
|
||||
<Text>Workspace directory not found.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ function normalizeBaseRef(baseRef: string | null | undefined): string {
|
||||
|
||||
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeKeyPart(workspaceId)}`
|
||||
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
@@ -38,7 +38,7 @@ interface UseWorkspaceTerminalsInput {
|
||||
workspaceDirectory: string | null;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
hasHydratedWorkspaces: boolean;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
isMissingWorkspaceDirectory: boolean;
|
||||
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
onScriptTerminalSelected: (terminalId: string) => void;
|
||||
onWorkspacePathUnavailable: () => void;
|
||||
@@ -56,7 +56,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreated,
|
||||
onScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable,
|
||||
@@ -209,7 +209,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
|
||||
setPendingCreateInput(null);
|
||||
onWorkspacePathUnavailable();
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
]);
|
||||
@@ -233,7 +233,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceDirectory) {
|
||||
onWorkspacePathUnavailable();
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +245,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreateQueued,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
|
||||
@@ -110,11 +110,7 @@ import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store"
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceRouteId,
|
||||
type WorkspaceExecutionAuthorityResult,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
@@ -170,7 +166,6 @@ import {
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { resolveCloseAgentTabPolicy } from "@/subagents";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
@@ -966,13 +961,13 @@ function useCloseTabs(): UseCloseTabsResult {
|
||||
|
||||
interface WorkspaceHeaderMenuProps {
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
currentBranchName: string | null;
|
||||
showWorkspaceSetup: boolean;
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
copyPathDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
@@ -1047,13 +1042,13 @@ function WorkspaceHeaderMenuTriggerIcon({
|
||||
|
||||
function WorkspaceHeaderMenu({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
currentBranchName,
|
||||
showWorkspaceSetup,
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
copyPathDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
@@ -1126,7 +1121,7 @@ function WorkspaceHeaderMenu({
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={menuCopyIcon}
|
||||
disabled={!isAbsolutePath(normalizedWorkspaceId)}
|
||||
disabled={copyPathDisabled}
|
||||
onSelect={onCopyWorkspacePath}
|
||||
>
|
||||
{t("workspace.header.actions.copyPath")}
|
||||
@@ -1198,6 +1193,7 @@ interface WorkspaceHeaderTitleBarProps {
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
copyPathDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
@@ -1233,6 +1229,7 @@ function WorkspaceHeaderTitleBar({
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
copyPathDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
@@ -1280,13 +1277,13 @@ function WorkspaceHeaderTitleBar({
|
||||
<View style={styles.compactHeaderMenuCluster}>
|
||||
<WorkspaceHeaderMenu
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={importAgentDisabled}
|
||||
copyPathDisabled={copyPathDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={menuNewBrowserIcon}
|
||||
@@ -1331,7 +1328,7 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||
}
|
||||
|
||||
interface RenderWorkspaceContentInput {
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
isMissingWorkspaceDirectory: boolean;
|
||||
activeTabDescriptor: WorkspaceTabDescriptor | null;
|
||||
hasHydratedAgents: boolean;
|
||||
mountedFocusedPaneTabIds: string[];
|
||||
@@ -1346,7 +1343,7 @@ interface RenderWorkspaceContentInput {
|
||||
|
||||
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
||||
const {
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
activeTabDescriptor,
|
||||
hasHydratedAgents,
|
||||
mountedFocusedPaneTabIds,
|
||||
@@ -1356,11 +1353,11 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
buildMobilePaneContentModel,
|
||||
} = input;
|
||||
|
||||
if (isMissingWorkspaceExecutionAuthority) {
|
||||
if (isMissingWorkspaceDirectory) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>
|
||||
Workspace execution directory is missing. Reload workspace data before opening tabs.
|
||||
Workspace directory is missing. Reload workspace data before opening tabs.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -1454,22 +1451,6 @@ function deriveWorkspaceHeaderFields(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceAuthorityState {
|
||||
workspaceDirectory: string | null;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
}
|
||||
|
||||
function resolveWorkspaceAuthorityState(
|
||||
workspaceAuthority: WorkspaceExecutionAuthorityResult,
|
||||
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
|
||||
): WorkspaceAuthorityState {
|
||||
const authority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
||||
return {
|
||||
workspaceDirectory: authority?.workspaceDirectory ?? null,
|
||||
isMissingWorkspaceExecutionAuthority: Boolean(workspaceDescriptor && !authority),
|
||||
};
|
||||
}
|
||||
|
||||
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
||||
const trimmed = host?.label?.trim();
|
||||
return trimmed ? trimmed : fallback;
|
||||
@@ -1777,15 +1758,8 @@ function WorkspaceScreenContent({
|
||||
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceAuthority = useMemo(
|
||||
() =>
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspace: workspaceDescriptor,
|
||||
}),
|
||||
[workspaceDescriptor],
|
||||
);
|
||||
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
|
||||
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
|
||||
const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null;
|
||||
const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory;
|
||||
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
|
||||
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
|
||||
const openImportSheet = useCallback(() => {
|
||||
@@ -1870,7 +1844,7 @@ function WorkspaceScreenContent({
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
onTerminalCreated: handleTerminalCreated,
|
||||
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||
@@ -3260,7 +3234,7 @@ function WorkspaceScreenContent({
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const content = renderWorkspaceContent({
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
isMissingWorkspaceDirectory,
|
||||
activeTabDescriptor,
|
||||
hasHydratedAgents,
|
||||
mountedFocusedPaneTabIds,
|
||||
@@ -3411,11 +3385,13 @@ function WorkspaceScreenContent({
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
{workspaceDirectory ? (
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
@@ -3654,6 +3630,7 @@ function WorkspaceScreenContent({
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
copyPathDisabled={!workspaceDirectory}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import { encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment } from "@/utils/host-routes";
|
||||
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
@@ -136,7 +136,7 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
return `workspace-browser-close-${tab.target.browserId}`;
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
|
||||
selection = normalized;
|
||||
revision += 1;
|
||||
notifyListeners();
|
||||
// workspaceId is opaque; do not parse this persisted selection back into a path.
|
||||
void storage.write(JSON.stringify(normalized)).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
@@ -77,7 +77,7 @@ export function navigateToWorkspace(
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(serverId)).filter(
|
||||
(agent) =>
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
}) === resolvedWorkspaceId,
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceExists,
|
||||
selectWorkspaceExecutionAuthority,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScopeForServer,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type WorkspaceStructure,
|
||||
} from "./selectors";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
|
||||
import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
|
||||
// These are the ONLY supported ways to read workspaces from the session store.
|
||||
@@ -72,14 +71,14 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceExecutionAuthority(
|
||||
export function useWorkspaceDirectory(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceExecutionAuthorityResult | null {
|
||||
): string | null {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId),
|
||||
workspaceEqualityFns.deep,
|
||||
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceExecutionAuthority,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScopeForServer,
|
||||
@@ -142,34 +142,30 @@ describe("selectWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectWorkspaceExecutionAuthority", () => {
|
||||
it("preserves the old missing-workspace message with the requested id", () => {
|
||||
describe("selectWorkspaceDirectory", () => {
|
||||
it("returns the workspace directory, never the opaque workspace id", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "wks_3f9a2b1c",
|
||||
workspaceDirectory: "/Users/dev/project",
|
||||
});
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const directory = selectWorkspaceDirectory(
|
||||
useSessionStore.getState(),
|
||||
SERVER_ID,
|
||||
"wks_3f9a2b1c",
|
||||
);
|
||||
|
||||
expect(directory).toBe("/Users/dev/project");
|
||||
expect(directory).not.toBe("wks_3f9a2b1c");
|
||||
});
|
||||
|
||||
it("returns null when the workspace is missing", () => {
|
||||
initializeWorkspaces([]);
|
||||
|
||||
expect(
|
||||
selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
message: "Workspace not found: missing-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps deep-equal authority references under unrelated workspace updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const tracked = trackSelector(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const before = tracked.current;
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
tracked.stop();
|
||||
selectWorkspaceDirectory(useSessionStore.getState(), SERVER_ID, "missing-id"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
type WorkspaceExecutionAuthorityResult,
|
||||
} from "@/utils/workspace-execution";
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
@@ -113,6 +111,14 @@ export function selectWorkspaceFields<T>(
|
||||
return workspace ? project(workspace) : null;
|
||||
}
|
||||
|
||||
export function selectWorkspaceDirectory(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): string | null {
|
||||
return selectWorkspace(state, serverId, workspaceId)?.workspaceDirectory || null;
|
||||
}
|
||||
|
||||
export function selectWorkspaceExists(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
@@ -128,20 +134,6 @@ export function selectHasHydratedWorkspaces(
|
||||
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
|
||||
}
|
||||
|
||||
export function selectWorkspaceExecutionAuthority(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceExecutionAuthorityResult | null {
|
||||
if (serverId === null || workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
return getWorkspaceExecutionAuthority({
|
||||
workspaces: state.sessions[serverId]?.workspaces,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function selectWorkspaceStructureProjects(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
@@ -257,7 +249,7 @@ export function selectResolveWorkspaceIdByCwd(
|
||||
return null;
|
||||
}
|
||||
const workspaces = state.sessions[serverId]?.workspaces;
|
||||
return resolveWorkspaceIdByExecutionDirectory({
|
||||
return resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
|
||||
@@ -99,6 +99,44 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
expect(workspace.scripts).not.toBe(scripts);
|
||||
});
|
||||
|
||||
it("canonicalizes the workspace directory and treats a blank one as empty", () => {
|
||||
const canonical = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/app/",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
});
|
||||
expect(canonical.workspaceDirectory).toBe("/repo/app");
|
||||
|
||||
const blank = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: " ",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
});
|
||||
expect(blank.workspaceDirectory).toBe("");
|
||||
});
|
||||
|
||||
it("defaults missing scripts to an empty array", () => {
|
||||
const payload = {
|
||||
id: "1",
|
||||
|
||||
@@ -27,8 +27,11 @@ import type {
|
||||
ServerCapabilities,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import {
|
||||
normalizeWorkspaceOpaqueId,
|
||||
normalizeWorkspacePath,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import {
|
||||
createAgentLastActivityCoalescer,
|
||||
type AgentLastActivityCommitter,
|
||||
@@ -149,7 +152,10 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
// Canonicalize the workspace directory once, at the store boundary, so every
|
||||
// consumer can read workspace.workspaceDirectory directly. Empty means "no
|
||||
// usable directory" (older daemons may omit it; the wire field is optional).
|
||||
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
name: payload.name,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("workspace route parsing", () => {
|
||||
it("keeps URL-safe workspace IDs unencoded", () => {
|
||||
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
|
||||
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
|
||||
expect(decodeWorkspaceIdFromPathSegment("wks_10b3479c955fcc4c")).toBe("wks_10b3479c955fcc4c");
|
||||
});
|
||||
|
||||
it("encodes non-URL-safe workspace IDs as base64url", () => {
|
||||
|
||||
@@ -100,6 +100,19 @@ function isLegacyPathLikeWorkspaceValue(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function hasLegacyDecodeNoise(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint == null) continue;
|
||||
if (codePoint < 0x20 || codePoint === 0x7f || codePoint === 0xfffd) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCleanLegacyPathDecode(value: string): boolean {
|
||||
return isLegacyPathLikeWorkspaceValue(value) && !hasLegacyDecodeNoise(value);
|
||||
}
|
||||
|
||||
export type WorkspaceOpenIntent =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
@@ -193,13 +206,14 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
|
||||
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
|
||||
}
|
||||
|
||||
// COMPAT(legacyPathWorkspaceId): IDs were path-shaped before v0.1.95. Remove when deep-link floor >= v0.2.0.
|
||||
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
|
||||
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
|
||||
if (base64Decoded && isCleanLegacyPathDecode(base64Decoded)) {
|
||||
return normalizeWorkspaceId(base64Decoded);
|
||||
}
|
||||
|
||||
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
|
||||
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
|
||||
if (relaxedBase64Decoded && isCleanLegacyPathDecode(relaxedBase64Decoded)) {
|
||||
return normalizeWorkspaceId(relaxedBase64Decoded);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
export interface NavigateToAgentInput {
|
||||
@@ -29,7 +29,7 @@ export function resolveNavigateToAgent(
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: agentCwd,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Href } from "expo-router";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||
|
||||
export function buildWorkspaceArchiveRedirectRoute(input: {
|
||||
serverId: string;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
|
||||
|
||||
describe("resolveWorkspaceDirectory", () => {
|
||||
it("canonicalizes a workspace directory and returns null when blank", () => {
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
|
||||
"C:/repo/app",
|
||||
);
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: " " })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireWorkspaceDirectory", () => {
|
||||
it("returns the canonical directory when present", () => {
|
||||
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo/app/" })).toBe("/repo/app");
|
||||
});
|
||||
|
||||
it("throws naming the workspace when the directory is missing", () => {
|
||||
expect(() =>
|
||||
requireWorkspaceDirectory({ workspaceId: "wks_1", workspaceDirectory: " " }),
|
||||
).toThrow("Workspace directory is missing for workspace wks_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export function resolveWorkspaceDirectory(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspacePath(input.workspaceDirectory);
|
||||
}
|
||||
|
||||
export function requireWorkspaceDirectory(input: {
|
||||
workspaceId?: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string {
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
});
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error(
|
||||
input.workspaceId
|
||||
? `Workspace directory is missing for workspace ${input.workspaceId}`
|
||||
: "Workspace directory is missing.",
|
||||
);
|
||||
}
|
||||
return workspaceDirectory;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceAuthorityResult {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
}
|
||||
|
||||
export type WorkspaceExecutionAuthorityFailureReason =
|
||||
| "workspace_id_missing"
|
||||
| "workspace_missing"
|
||||
| "workspace_directory_missing";
|
||||
|
||||
export type WorkspaceExecutionAuthorityResult =
|
||||
| { ok: true; authority: WorkspaceAuthorityResult }
|
||||
| {
|
||||
ok: false;
|
||||
reason: WorkspaceExecutionAuthorityFailureReason;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function resolveWorkspaceRouteId(input: {
|
||||
routeWorkspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceIdByExecutionDirectory(input: {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!normalizedWorkspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const workspace of input.workspaces ?? []) {
|
||||
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
|
||||
return workspace.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceMapKeyByIdentity(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaces = input.workspaces;
|
||||
if (!workspaces) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspaces.has(normalizedWorkspaceId)) {
|
||||
return normalizedWorkspaceId;
|
||||
}
|
||||
|
||||
for (const [workspaceKey, workspace] of workspaces) {
|
||||
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
|
||||
return workspaceKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceExecutionAuthorityResult {
|
||||
const workspace =
|
||||
"workspace" in input
|
||||
? input.workspace
|
||||
: (() => {
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: input.workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaces?.get(workspaceKey) ?? null;
|
||||
})();
|
||||
|
||||
if ("workspaces" in input) {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_id_missing",
|
||||
message: "Workspace id is required.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_missing",
|
||||
message:
|
||||
"workspaces" in input
|
||||
? `Workspace not found: ${input.workspaceId ?? ""}`
|
||||
: "Workspace not found.",
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "workspace_directory_missing",
|
||||
message: `Workspace directory is missing for workspace ${workspace.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authority: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceDirectory,
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requireWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceAuthorityResult {
|
||||
const result = getWorkspaceExecutionAuthority(input);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
return result.authority;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceExecutionDirectory(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspacePath(input.workspaceDirectory);
|
||||
}
|
||||
|
||||
export function requireWorkspaceExecutionDirectory(input: {
|
||||
workspaceId?: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string {
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
});
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error(
|
||||
input.workspaceId
|
||||
? `Workspace directory is missing for workspace ${input.workspaceId}`
|
||||
: "Workspace directory is missing.",
|
||||
);
|
||||
}
|
||||
return workspaceDirectory;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceExecutionAuthority(
|
||||
input:
|
||||
| {
|
||||
workspace: WorkspaceDescriptor | null | undefined;
|
||||
}
|
||||
| {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
},
|
||||
): WorkspaceAuthorityResult | null {
|
||||
const result = getWorkspaceExecutionAuthority(input);
|
||||
return result.ok ? result.authority : null;
|
||||
}
|
||||
+5
-70
@@ -1,12 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
getWorkspaceExecutionAuthority,
|
||||
requireWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceRouteId,
|
||||
} from "./workspace-execution";
|
||||
} from "./workspace-identity";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
@@ -40,7 +38,7 @@ describe("resolveWorkspaceRouteId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
describe("resolveWorkspaceIdByDirectory", () => {
|
||||
it("matches workspace directories", () => {
|
||||
const workspaces = [
|
||||
createWorkspace({
|
||||
@@ -51,7 +49,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
@@ -68,7 +66,7 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
@@ -115,66 +113,3 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace execution authority", () => {
|
||||
it("returns an explicit failure when workspace id is missing", () => {
|
||||
expect(
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspaces: new Map(),
|
||||
workspaceId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "workspace_id_missing",
|
||||
message: "Workspace id is required.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an explicit failure when workspace directory is missing", () => {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>([
|
||||
[
|
||||
"workspace-1",
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
workspaceDirectory: " ",
|
||||
projectRootPath: "/repo",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspaces,
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "Workspace directory is missing for workspace workspace-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("never falls back to project root metadata", () => {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>([
|
||||
[
|
||||
"workspace-1",
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
requireWorkspaceExecutionAuthority({
|
||||
workspaces,
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceId: "workspace-1",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
workspace: workspaces.get("workspace-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -22,3 +24,56 @@ export function normalizeWorkspacePath(value: string | null | undefined): string
|
||||
const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, "");
|
||||
return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/";
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRouteId(input: {
|
||||
routeWorkspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
|
||||
}
|
||||
|
||||
// Single approved cwd→workspaceId inference site.
|
||||
// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it.
|
||||
export function resolveWorkspaceIdByDirectory(input: {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!normalizedWorkspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const workspace of input.workspaces ?? []) {
|
||||
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
|
||||
return workspace.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceMapKeyByIdentity(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaces = input.workspaces;
|
||||
if (!workspaces) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspaces.has(normalizedWorkspaceId)) {
|
||||
return normalizedWorkspaceId;
|
||||
}
|
||||
|
||||
for (const [workspaceKey, workspace] of workspaces) {
|
||||
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
|
||||
return workspaceKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
function normalizeWorkspaceId(value: string | null | undefined): string {
|
||||
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
|
||||
return normalizeWorkspacePath(value) ?? "";
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
@@ -32,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
@@ -44,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
|
||||
Reference in New Issue
Block a user