Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,17 @@ export interface UseHostWorkspacesResult {
* stale cloud rows. Query keys are shared with the provider, so a scoped
* call where the provider is mounted fetches the host once, not twice.
* Passing null resolves no target and runs nothing (stays !isReady).
*
* `includeArchived` additionally fetches archived tombstones (soft-deleted
* workspaces) under a separate query key — the shared live cache and its
* snapshots never see archived rows. Tombstones append after live rows with
* `archivedAt`/`archiveReason` set.
*/
export function useHostWorkspacesSource(
scopedHostId?: string | null,
options?: { includeArchived?: boolean },
): UseHostWorkspacesResult {
const includeArchived = options?.includeArchived ?? false;
const collections = useCollections();
const queryClient = useQueryClient();
const { activeHostUrl, machineId } = useLocalHostService();
Expand Down Expand Up @@ -163,6 +170,31 @@ export function useHostWorkspacesSource(
})),
});

// Archived tombstones, opt-in, own query key: the shared live list (and
// its persisted snapshots) must never contain archived rows.
const archivedQueries = useQueries({
queries: targets.map((target) => ({
queryKey: [
"host-service",
"workspaces",
"archived",
target.organizationId,
target.machineId,
] as const,
enabled: includeArchived && target.hostUrl !== null,
refetchInterval: WORKSPACES_FALLBACK_REFETCH_INTERVAL_MS,
networkMode: "always" as const,
queryFn: async (): Promise<HostWorkspaceRow[]> => {
if (!target.hostUrl) return [];
const client = getHostServiceClientByUrl(target.hostUrl);
const rows = (await client.workspace.list.query({
includeArchived: true,
})) as HostWorkspaceRow[];
return rows.filter((row) => row.archivedAt != null);
},
})),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// Live updates: each reachable host's workspace:changed patches its own
// cached list (and the snapshot) without a refetch.
useEffect(() => {
Expand Down Expand Up @@ -197,6 +229,19 @@ export function useHostWorkspacesSource(
return next;
},
);
// Archive state flips arrive as deleted/created events; the
// tombstone list has no patch payload, so refetch it.
if (includeArchived) {
void queryClient.invalidateQueries({
queryKey: [
"host-service",
"workspaces",
"archived",
target.organizationId,
target.machineId,
],
});
}
},
);
const releaseBus = bus.retain();
Expand All @@ -208,24 +253,48 @@ export function useHostWorkspacesSource(
return () => {
for (const cleanup of cleanups) cleanup();
};
}, [targets, queryClient]);
}, [targets, queryClient, includeArchived]);

const workspaces = useMemo(
() =>
mergeHostWorkspaces({
hostResults: targets.map((target, index) => {
const query = queries[index];
const live = query?.data;
return {
target,
rows: live ?? snapshots.get(target.machineId),
reachable: live !== undefined && !query?.isError,
};
}),
cloudRows: scopedHostId === undefined ? cloudRows : [],
const workspaces = useMemo(() => {
const merged = mergeHostWorkspaces({
hostResults: targets.map((target, index) => {
const query = queries[index];
const live = query?.data;
return {
target,
rows: live ?? snapshots.get(target.machineId),
reachable: live !== undefined && !query?.isError,
};
}),
[targets, queries, snapshots, cloudRows, scopedHostId],
);
cloudRows: scopedHostId === undefined ? cloudRows : [],
});
if (!includeArchived) return merged;
// Tombstones append after live rows; consumers dedupe by id, so a row
// mid-unarchive can't render twice.
const liveIds = new Set(merged.map((row) => row.id));
const archived: HostWorkspaceItem[] = targets.flatMap((_target, index) => {
const query = archivedQueries[index];
const rows = query?.data ?? [];
return rows
.filter((row) => !liveIds.has(row.id))
.map((row) => ({
...row,
// react-query retains prior data across a failed refetch —
// don't report a host as answering when it did not.
hostReachable: !query?.isError,
source: "host" as const,
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
return [...merged, ...archived];
}, [
targets,
queries,
snapshots,
cloudRows,
scopedHostId,
includeArchived,
archivedQueries,
]);

// Readiness reflects host-query settlement only. The Electric collection
// is a fallback merge, NOT a gate: an Electric collection can stay
Expand All @@ -248,7 +317,14 @@ export function useHostWorkspacesSource(
query.isError ||
targets[index]?.hostUrl === null ||
snapshots.has(targets[index]?.machineId ?? ""),
);
) &&
// Tombstones count toward settlement too: an archived-only host must
// not read as a settled empty view while its archived query loads.
(!includeArchived ||
archivedQueries.every(
(query, index) =>
query.isSuccess || query.isError || targets[index]?.hostUrl === null,
));

const cache = useMemo<HostWorkspacesCacheOps>(() => {
const targetFor = (hostId: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type HostShapedWorkspace = Omit<
export interface HostWorkspaceRow extends HostShapedWorkspace {
worktreePath: string;
worktreeExists: boolean;
/** Non-null = archived tombstone (only served on `includeArchived`). */
archivedAt?: number | null;
archiveReason?: "merged" | "deleted" | null;
}

/** Merged item returned by useHostWorkspaces. */
Expand All @@ -34,6 +37,9 @@ export interface HostWorkspaceItem extends HostShapedWorkspace {
hostReachable: boolean;
/** "host" = served by a host (live or last-seen); "cloud" = Electric fallback. */
source: "host" | "cloud";
/** Non-null = archived tombstone (only present on `includeArchived`). */
archivedAt?: number | null;
archiveReason?: "merged" | "deleted" | null;
}

export interface HostWorkspacesQueryTarget {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences/useV2UserPreferences";
import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace";
import type { DashboardSidebarWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types";
import { useDeletingWorkspacesStore } from "renderer/routes/_authenticated/_dashboard/stores/deletingWorkspacesStore";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider";
import {
type BulkWorkspaceInspectionState,
Expand Down Expand Up @@ -51,7 +51,6 @@ export function useBulkWorkspaceDelete({
onDeleted,
}: UseBulkWorkspaceDeleteOptions) {
const { cache: hostWorkspacesCache } = useHostWorkspaces();
const { markDeleting, clearDeleting } = useDeletingWorkspaces();
const { navigateAwayFromWorkspace } = useNavigateAwayFromWorkspace();
const { removeWorkspaceFromSidebar } = useDashboardSidebarState();
const { preferences, setDeleteLocalBranch } = useV2UserPreferences();
Expand Down Expand Up @@ -138,7 +137,9 @@ export function useBulkWorkspaceDelete({
let deletedIds: string[] = [];
let selectionReconciled = false;
try {
for (const workspace of targets) markDeleting(workspace.id);
for (const workspace of targets) {
useDeletingWorkspacesStore.getState().markDeleting(workspace.id);
}
for (const workspace of targets) {
navigateAwayFromWorkspace(workspace.id, targetIds);
}
Expand Down Expand Up @@ -235,16 +236,16 @@ export function useBulkWorkspaceDelete({
},
);
} finally {
for (const workspace of targets) clearDeleting(workspace.id);
for (const workspace of targets) {
useDeletingWorkspacesStore.getState().clearDeleting(workspace.id);
}
setIsDeleting(false);
inFlight.current = false;
}
},
[
clearDeleting,
hostWorkspacesCache,
inspections,
markDeleting,
navigateAwayFromWorkspace,
onDeleted,
onOpenChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "renderer/hooks/host-service/useDestroyWorkspace";
import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences/useV2UserPreferences";
import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { useDeletingWorkspacesStore } from "renderer/routes/_authenticated/_dashboard/stores/deletingWorkspacesStore";
import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider";

interface UseDestroyDialogStateOptions {
Expand All @@ -37,7 +37,6 @@ export function useDestroyDialogState({
const { destroy, inspect, hostTarget } = useDestroyWorkspace(workspaceId);
const { workspaces: hostWorkspaces, cache: hostWorkspacesCache } =
useHostWorkspaces();
const { markDeleting, clearDeleting } = useDeletingWorkspaces();
const { navigateAwayFromWorkspace } = useNavigateAwayFromWorkspace();

const { preferences, setDeleteLocalBranch: setDeleteBranch } =
Expand Down Expand Up @@ -102,7 +101,9 @@ export function useDestroyDialogState({

setError(null);
onOpenChange(false);
markDeleting(workspaceId);
// The row stays listed until the archive commit (post-teardown);
// mark it so navigation/shortcuts skip it in the meantime.
useDeletingWorkspacesStore.getState().markDeleting(workspaceId);
// Navigate up-front: no-ops if the deleted workspace isn't the
// active route, so a later user navigation won't be hijacked.
navigateAwayFromWorkspace(workspaceId);
Expand Down Expand Up @@ -151,7 +152,7 @@ export function useDestroyDialogState({
);
}
} finally {
clearDeleting(workspaceId);
useDeletingWorkspacesStore.getState().clearDeleting(workspaceId);
inFlight.current = false;
}
},
Expand All @@ -162,8 +163,6 @@ export function useDestroyDialogState({
workspaceId,
onOpenChange,
onDeleted,
markDeleting,
clearDeleting,
navigateAwayFromWorkspace,
hostWorkspaces,
hostWorkspacesCache,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import { useDiffStats } from "renderer/hooks/host-service/useDiffStats";
import { useV2WorkspaceNotificationStatus } from "renderer/hooks/host-service/useV2NotificationStatus";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components";
import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider";
import type { WorkspaceSelectionEvent } from "../../providers/DashboardSidebarSelectionProvider";
Expand Down Expand Up @@ -108,9 +107,6 @@ export function DashboardSidebarWorkspaceItem({
v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName });
};
const isPending = pendingTransaction?.type === "insert";
// Keep the delete dialog outside the hidden wrapper below — the destroy
// flow reopens it into an error pane on conflict/teardown-failed.
const isDeleting = useDeletingWorkspaces().isDeleting(id);

const {
hoveredId: hoverHoveredId,
Expand Down Expand Up @@ -230,7 +226,7 @@ export function DashboardSidebarWorkspaceItem({

return (
<>
<div hidden={isDeleting}>
<div>
{isPending ? (
content
) : (
Expand Down Expand Up @@ -329,7 +325,7 @@ export function DashboardSidebarWorkspaceItem({

return (
<>
<div hidden={isDeleting}>
<div>
{isPending ? (
expandedContent
) : isBulkMenu ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMatchRoute, useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo, useRef } from "react";
import { useHotkey } from "renderer/hotkeys";
import { useDeletingWorkspacesStore } from "renderer/routes/_authenticated/_dashboard/stores/deletingWorkspacesStore";
import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import type {
DashboardSidebarProject,
DashboardSidebarWorkspace,
Expand Down Expand Up @@ -59,7 +59,7 @@ export function useDashboardSidebarShortcuts(
const navigate = useNavigate();
const { toggleProjectCollapsed, toggleSectionCollapsed } =
useDashboardSidebarState();
const { isDeleting } = useDeletingWorkspaces();
const deletingIds = useDeletingWorkspacesStore((state) => state.deletingIds);
const flattenedWorkspaces = useMemo(
() =>
[
Expand All @@ -68,8 +68,10 @@ export function useDashboardSidebarShortcuts(
...groups.flatMap((project) =>
getProjectChildrenWorkspaces(project.children),
),
].filter((workspace) => !isDeleting(workspace.id)),
[groups, sessionWorkspaces, isDeleting],
// A destroy in flight keeps its row until the archive commit —
// don't hand shortcuts a workspace that is about to vanish.
].filter((workspace) => !deletingIds.has(workspace.id)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Prev/next shortcuts can still navigate into a workspace mid-destroy because the workspace page's concurrent handlers bypass this filtered list. Have those handlers consult deletingIds (or consolidate the bindings) so every shortcut target excludes in-flight deletes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts, line 73:

<comment>Prev/next shortcuts can still navigate into a workspace mid-destroy because the workspace page's concurrent handlers bypass this filtered list. Have those handlers consult `deletingIds` (or consolidate the bindings) so every shortcut target excludes in-flight deletes.</comment>

<file context>
@@ -58,15 +59,19 @@ export function useDashboardSidebarShortcuts(
+				),
+				// A destroy in flight keeps its row until the archive commit —
+				// don't hand shortcuts a workspace that is about to vanish.
+			].filter((workspace) => !deletingIds.has(workspace.id)),
+		[groups, sessionWorkspaces, deletingIds],
 	);
</file context>

[groups, sessionWorkspaces, deletingIds],
);
const workspaceShortcutLabels =
useStableWorkspaceShortcutLabels(flattenedWorkspaces);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMatchRoute, useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import { useDeletingWorkspacesStore } from "renderer/routes/_authenticated/_dashboard/stores/deletingWorkspacesStore";
import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider";
import { getFlattenedV2WorkspaceIds } from "../../utils/getFlattenedV2WorkspaceIds";
import { resolveWorkspaceRemovalNavigationTarget } from "./navigationTarget";
Expand All @@ -26,7 +26,6 @@ export function useNavigateAwayFromWorkspace() {
() => new Set(workspaces.map((workspace) => workspace.id)),
[workspaces],
);
const { isDeleting } = useDeletingWorkspaces();

const navigateAwayFromWorkspace = useCallback(
(
Expand All @@ -47,8 +46,12 @@ export function useNavigateAwayFromWorkspace() {
// "unknown", not "gone" — prefer navigating to it over home; the
// workspace route's own not-found handling covers a true miss.
isWorkspaceValid: (id) => !isReady || workspaceIds.has(id),
// Rows mid-destroy stay listed until the archive commit lands
// (after teardown) — exclude every in-flight destroy, not just
// the caller's own batch. Read at call time for freshness.
isWorkspaceDeleting: (id) =>
additionalDeletingWorkspaceIds?.has(id) === true || isDeleting(id),
additionalDeletingWorkspaceIds?.has(id) === true ||
useDeletingWorkspacesStore.getState().deletingIds.has(id),
});

if (!target) return;
Expand All @@ -62,7 +65,7 @@ export function useNavigateAwayFromWorkspace() {
reportRemovalNavigationError,
);
},
[collections, workspaceIds, isDeleting, matchRoute, navigate, isReady],
[collections, workspaceIds, matchRoute, navigate, isReady],
);

return { navigateAwayFromWorkspace };
Expand Down
Loading
Loading