Bound retained workspace screens (#1472)
* fix(app): prune retained workspace deck entries * fix(app): narrow workspace deck retention selectors * fix(app): preserve deck lru during route transitions * test(app): update startup reconnect expectation
This commit is contained in:
@@ -139,17 +139,18 @@ class StartupAssertions {
|
|||||||
this.page = page;
|
this.page = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
async expectsReconnectWelcome(): Promise<this> {
|
async expectsSavedHostShell(input: { label: string }): Promise<this> {
|
||||||
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
|
||||||
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
timeout: 15_000,
|
||||||
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
});
|
||||||
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
|
||||||
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
|
||||||
|
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
async expectsNoSavedHostErrorStatus(): Promise<this> {
|
||||||
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
|
||||||
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
||||||
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
import { startupScenario } from "./helpers/startup-dsl";
|
import { startupScenario } from "./helpers/startup-dsl";
|
||||||
|
|
||||||
test.describe("Startup loading presentation", () => {
|
test.describe("Startup loading presentation", () => {
|
||||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
test("mobile reconnect preserves the saved host shell", async ({ page }) => {
|
||||||
const startup = await startupScenario(page)
|
const startup = await startupScenario(page)
|
||||||
.withMobileViewport()
|
.withMobileViewport()
|
||||||
.withSavedHost({
|
.withSavedHost({
|
||||||
@@ -14,8 +14,8 @@ test.describe("Startup loading presentation", () => {
|
|||||||
})
|
})
|
||||||
.openRoot();
|
.openRoot();
|
||||||
|
|
||||||
await startup.expectsReconnectWelcome();
|
await startup.expectsSavedHostShell({ label: "Dev" });
|
||||||
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
await startup.expectsNoSavedHostErrorStatus();
|
||||||
await startup.expectsNoLocalServerStartupCopy();
|
await startup.expectsNoLocalServerStartupCopy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
@@ -7,9 +7,18 @@ import {
|
|||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
|
import {
|
||||||
|
areWorkspaceSelectionListsEqual,
|
||||||
|
areWorkspaceSelectionsEqual,
|
||||||
|
getWorkspaceSelectionKey,
|
||||||
|
pruneMountedWorkspaceSelections,
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted,
|
||||||
|
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||||
|
} from "@/screens/workspace/workspace-deck-retention";
|
||||||
import {
|
import {
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
@@ -152,28 +161,33 @@ function HostWorkspaceRouteContent() {
|
|||||||
return <WorkspaceDeck />;
|
return <WorkspaceDeck />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areWorkspaceSelectionsEqual(
|
|
||||||
left: ActiveWorkspaceSelection | null,
|
|
||||||
right: ActiveWorkspaceSelection | null,
|
|
||||||
): boolean {
|
|
||||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceDeck() {
|
function WorkspaceDeck() {
|
||||||
const activeSelection = useActiveWorkspaceSelection();
|
const activeSelection = useActiveWorkspaceSelection();
|
||||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
activeSelection ? [activeSelection] : [],
|
activeSelection ? [activeSelection] : [],
|
||||||
);
|
);
|
||||||
|
const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => {
|
||||||
|
setMountedSelections((current) =>
|
||||||
|
current.filter(
|
||||||
|
(mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSelection) {
|
if (!activeSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMountedSelections((current) => {
|
setMountedSelections((current) => {
|
||||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
const next = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: current,
|
||||||
|
activeSelection,
|
||||||
|
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||||
|
});
|
||||||
|
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
return [...current, activeSelection];
|
return next;
|
||||||
});
|
});
|
||||||
}, [activeSelection]);
|
}, [activeSelection]);
|
||||||
|
|
||||||
@@ -184,25 +198,61 @@ function WorkspaceDeck() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.deck}>
|
<View style={styles.deck}>
|
||||||
{mountedSelections.map((selection) => {
|
{mountedSelections.map((selection) => {
|
||||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
|
||||||
return (
|
return (
|
||||||
<View
|
<WorkspaceDeckEntry
|
||||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
key={getWorkspaceSelectionKey(selection)}
|
||||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
selection={selection}
|
||||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
activeSelection={activeSelection}
|
||||||
>
|
onUnmountInactive={unmountWorkspaceSelection}
|
||||||
<WorkspaceScreen
|
/>
|
||||||
serverId={selection.serverId}
|
|
||||||
workspaceId={selection.workspaceId}
|
|
||||||
isRouteFocused={isActive}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceDeckEntry({
|
||||||
|
selection,
|
||||||
|
activeSelection,
|
||||||
|
onUnmountInactive,
|
||||||
|
}: {
|
||||||
|
selection: ActiveWorkspaceSelection;
|
||||||
|
activeSelection: ActiveWorkspaceSelection;
|
||||||
|
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||||
|
}) {
|
||||||
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
|
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
|
||||||
|
const workspaceExists = useWorkspaceExists(selection.serverId, selection.workspaceId);
|
||||||
|
const shouldKeepMounted = shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive,
|
||||||
|
hasHydratedWorkspaces,
|
||||||
|
workspaceExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldKeepMounted) {
|
||||||
|
onUnmountInactive(selection);
|
||||||
|
}
|
||||||
|
}, [onUnmountInactive, selection, shouldKeepMounted]);
|
||||||
|
|
||||||
|
if (!shouldKeepMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||||
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
>
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={selection.serverId}
|
||||||
|
workspaceId={selection.workspaceId}
|
||||||
|
isRouteFocused={isActive}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
deck: {
|
deck: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import {
|
||||||
|
pruneMountedWorkspaceSelections,
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted,
|
||||||
|
} from "@/screens/workspace/workspace-deck-retention";
|
||||||
|
|
||||||
|
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
|
||||||
|
return { serverId, workspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||||
|
return selections.map((selection) => selection.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pruneMountedWorkspaceSelections", () => {
|
||||||
|
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||||
|
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: [],
|
||||||
|
activeSelection: workspace("A"),
|
||||||
|
});
|
||||||
|
const mountedAfterB = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: mountedAfterA,
|
||||||
|
activeSelection: workspace("B"),
|
||||||
|
});
|
||||||
|
const mountedAfterC = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: mountedAfterB,
|
||||||
|
activeSelection: workspace("C"),
|
||||||
|
});
|
||||||
|
const mountedAfterD = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: mountedAfterC,
|
||||||
|
activeSelection: workspace("D"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the active workspace", () => {
|
||||||
|
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: [workspace("A")],
|
||||||
|
activeSelection: workspace("A"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates retained workspace selections", () => {
|
||||||
|
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: [workspace("B"), workspace("A"), workspace("B")],
|
||||||
|
activeSelection: workspace("A"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always allows at least the active workspace", () => {
|
||||||
|
const mountedSelections = pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections: [workspace("A"), workspace("B")],
|
||||||
|
activeSelection: workspace("C"),
|
||||||
|
maxMountedWorkspaces: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||||
|
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
|
||||||
|
expect(
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive: true,
|
||||||
|
hasHydratedWorkspaces: true,
|
||||||
|
workspaceExists: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inactive workspaces mounted until workspace hydration finishes", () => {
|
||||||
|
expect(
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive: false,
|
||||||
|
hasHydratedWorkspaces: false,
|
||||||
|
workspaceExists: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmounts inactive workspaces that are gone after hydration", () => {
|
||||||
|
expect(
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive: false,
|
||||||
|
hasHydratedWorkspaces: true,
|
||||||
|
workspaceExists: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inactive workspaces that still exist after hydration", () => {
|
||||||
|
expect(
|
||||||
|
shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive: false,
|
||||||
|
hasHydratedWorkspaces: true,
|
||||||
|
workspaceExists: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
|
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 3;
|
||||||
|
|
||||||
|
interface PruneMountedWorkspaceSelectionsInput {
|
||||||
|
currentSelections: ActiveWorkspaceSelection[];
|
||||||
|
activeSelection: ActiveWorkspaceSelection | null;
|
||||||
|
maxMountedWorkspaces?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceDeckEntryMountInput {
|
||||||
|
isActive: boolean;
|
||||||
|
hasHydratedWorkspaces: boolean;
|
||||||
|
workspaceExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string {
|
||||||
|
return `${selection.serverId}:${selection.workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areWorkspaceSelectionsEqual(
|
||||||
|
left: ActiveWorkspaceSelection | null,
|
||||||
|
right: ActiveWorkspaceSelection | null,
|
||||||
|
): boolean {
|
||||||
|
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areWorkspaceSelectionListsEqual(
|
||||||
|
left: ActiveWorkspaceSelection[],
|
||||||
|
right: ActiveWorkspaceSelection[],
|
||||||
|
): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return left.every((selection, index) =>
|
||||||
|
areWorkspaceSelectionsEqual(selection, right[index] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneMountedWorkspaceSelections({
|
||||||
|
currentSelections,
|
||||||
|
activeSelection,
|
||||||
|
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||||
|
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||||
|
if (!activeSelection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||||
|
const nextSelections: ActiveWorkspaceSelection[] = [];
|
||||||
|
const seenSelectionKeys = new Set<string>();
|
||||||
|
|
||||||
|
function appendSelection(selection: ActiveWorkspaceSelection): void {
|
||||||
|
if (nextSelections.length >= maxSelections) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectionKey = getWorkspaceSelectionKey(selection);
|
||||||
|
if (seenSelectionKeys.has(selectionKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenSelectionKeys.add(selectionKey);
|
||||||
|
nextSelections.push(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendSelection(activeSelection);
|
||||||
|
|
||||||
|
for (const selection of currentSelections) {
|
||||||
|
if (areWorkspaceSelectionsEqual(selection, activeSelection)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendSelection(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSelections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldKeepWorkspaceDeckEntryMounted({
|
||||||
|
isActive,
|
||||||
|
hasHydratedWorkspaces,
|
||||||
|
workspaceExists,
|
||||||
|
}: WorkspaceDeckEntryMountInput): boolean {
|
||||||
|
if (isActive) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!hasHydratedWorkspaces) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return workspaceExists;
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
|
|||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import {
|
import {
|
||||||
composeWorkspaceStructure,
|
composeWorkspaceStructure,
|
||||||
|
selectHasHydratedWorkspaces,
|
||||||
selectHasWorkspaces,
|
selectHasWorkspaces,
|
||||||
selectProjectOrder,
|
selectProjectOrder,
|
||||||
selectRecommendedProjectPaths,
|
selectRecommendedProjectPaths,
|
||||||
selectResolveWorkspaceIdByCwd,
|
selectResolveWorkspaceIdByCwd,
|
||||||
selectWorkspace,
|
selectWorkspace,
|
||||||
|
selectWorkspaceExists,
|
||||||
selectWorkspaceExecutionAuthority,
|
selectWorkspaceExecutionAuthority,
|
||||||
selectWorkspaceFields,
|
selectWorkspaceFields,
|
||||||
selectWorkspaceKeys,
|
selectWorkspaceKeys,
|
||||||
@@ -54,6 +56,22 @@ export function useWorkspaceFields<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceExists(serverId: string | null, workspaceId: string | null): boolean {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => selectWorkspaceExists(state, serverId, workspaceId),
|
||||||
|
workspaceEqualityFns.identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHasHydratedWorkspaces(serverId: string | null): boolean {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => selectHasHydratedWorkspaces(state, serverId),
|
||||||
|
workspaceEqualityFns.identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkspaceExecutionAuthority(
|
export function useWorkspaceExecutionAuthority(
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
|||||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||||
|
|
||||||
export interface SessionsSnapshot {
|
export interface SessionsSnapshot {
|
||||||
sessions: Record<string, { workspaces: Map<string, WorkspaceDescriptor> }>;
|
sessions: Record<
|
||||||
|
string,
|
||||||
|
{ hasHydratedWorkspaces?: boolean; workspaces: Map<string, WorkspaceDescriptor> }
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarOrderSnapshot {
|
export interface SidebarOrderSnapshot {
|
||||||
@@ -110,6 +113,21 @@ export function selectWorkspaceFields<T>(
|
|||||||
return workspace ? project(workspace) : null;
|
return workspace ? project(workspace) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectWorkspaceExists(
|
||||||
|
state: SessionsSnapshot,
|
||||||
|
serverId: string | null,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): boolean {
|
||||||
|
return selectWorkspace(state, serverId, workspaceId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectHasHydratedWorkspaces(
|
||||||
|
state: SessionsSnapshot,
|
||||||
|
serverId: string | null,
|
||||||
|
): boolean {
|
||||||
|
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
|
||||||
|
}
|
||||||
|
|
||||||
export function selectWorkspaceExecutionAuthority(
|
export function selectWorkspaceExecutionAuthority(
|
||||||
state: SessionsSnapshot,
|
state: SessionsSnapshot,
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user