diff --git a/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md b/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md new file mode 100644 index 00000000000..306742b7fe1 --- /dev/null +++ b/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md @@ -0,0 +1,342 @@ +# Sidebar "Remove from Sidebar" fix + sidebar re-render review + +Status: Part 1 targeted fixes and Part 2 low-risk sidebar re-render hardening +implemented. The remote "Remove from Sidebar" bug is real, and the +implementation now addresses both re-add paths identified below. The proposed +broad `v2WorkspaceLocalState` split is not validated by the current evidence +and should not be implemented as the fix for sidebar re-renders without new +profiling data. + +## Review verdict + +1. **Bug validity:** real. Removing the currently viewed remote workspace can + be undone by still-mounted workspace code that calls `ensureWorkspaceInSidebar`. +2. **Previous provider-only fix correctness:** not correct as written. The + provider value was still unstable because `getCollections(activeOrganizationId)` + returns a new wrapper object on every call. +3. **Regression risk:** the implemented targeted fixes are low risk. The collection + split is higher risk and currently unsupported by the behavior I verified. + +## Part 1 - bug: "Remove from Sidebar" does nothing for remote workspaces + +### Repro + +1. Have at least two v2 workspaces in the sidebar where one is a remote + workspace whose `host.machineId` is not the current device. +2. Be on `/v2-workspace/`. +3. Right-click the row and choose **Remove from Sidebar**. + +Expected: the row disappears and the app navigates to the next sidebar +workspace. + +Observed: the row stays visible, or briefly disappears and then reappears. + +The bug often looks remote-only because remote workspace route transitions keep +the source workspace subtree alive long enough for a re-add path to win. + +### Remove call chain + +1. `DashboardSidebarWorkspaceContextMenu.tsx:144` calls + `onSelect={onRemoveFromSidebar}`. +2. `DashboardSidebarWorkspaceItem.tsx:130/196` wires that prop to + `handleRemoveFromSidebar`. +3. `useDashboardSidebarWorkspaceItemActions.ts:73`: + ```ts + const handleRemoveFromSidebar = () => { + navigateAway(workspaceId); + removeWorkspaceFromSidebar(workspaceId); + }; + ``` +4. `useNavigateAwayFromWorkspace.ts:17` only navigates if the removed workspace + is the current route. +5. `useDashboardSidebarState.ts:430` deletes the local sidebar row: + ```ts + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); + collections.v2WorkspaceLocalState.delete(workspaceId); + ``` + +### Re-add paths + +There are two relevant paths that can reinsert a just-deleted sidebar row while +the old workspace page is still mounted. + +#### 1. Unstable provider value re-runs the mount ensure effect + +`useV2WorkspacePaneLayout.ts:55` has this effect: + +```ts +useEffect(() => { + ensureWorkspaceInSidebar(workspaceId, projectId); +}, [ensureWorkspaceInSidebar, projectId, workspaceId]); +``` + +`ensureWorkspaceInSidebar` comes from `useDashboardSidebarState`, whose callbacks +depend on `[collections]`. If `useCollections()` returns a new object reference +on each provider render, this callback changes identity and the effect runs +again. The effect calls `ensureSidebarWorkspaceRecord`, which inserts the row +if missing. + +This makes the old sequence: + +1. `removeWorkspaceFromSidebar` deletes the local-state row. +2. A provider render changes the `collections` object reference. +3. `ensureWorkspaceInSidebar` identity changes. +4. The still-mounted workspace pane-layout effect re-runs and re-inserts the row. + +#### 2. Pane-layout persistence re-adds before checking row existence + +`useV2WorkspacePaneLayout.ts:69` subscribes to the pane store. Inside the +subscription, it currently calls `ensureWorkspaceInSidebar(workspaceId, +projectId)` before checking whether a local row exists: + +```ts +ensureWorkspaceInSidebar(workspaceId, projectId); +if (!collections.v2WorkspaceLocalState.get(workspaceId)) { + return; +} +``` + +If the removed workspace remains mounted and the pane store emits, this call can +recreate the row even if the provider value has been stabilized. This is a +separate re-add path and should be fixed directly. + +### Why the previous provider-only fix was incomplete + +The previous diff memoized `contextValue` like this: + +```ts +const collections = activeOrganizationId + ? getCollections(activeOrganizationId) + : null; + +const contextValue = useMemo( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], +); +``` + +That does not stabilize the provider. `getCollections()` returns: + +```ts +return { + ...orgCollections, + organizations: organizationsCollection, +}; +``` + +So `collections` is a fresh wrapper object on every render, and the +`useMemo([collections, switchOrganization])` recomputes every render. + +### Implemented targeted fixes + +#### Fix A - stabilize the provider value for real + +The implementation memoizes the `getCollections` call in `CollectionsProvider`: + +```tsx +const collections = useMemo( + () => (activeOrganizationId ? getCollections(activeOrganizationId) : null), + [activeOrganizationId], +); + +const contextValue = useMemo( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], +); +``` + +This keeps collection caching behavior scoped to the provider while making the +context value stable across unrelated parent renders. + +#### Fix B - do not auto-ensure from pane-layout persistence + +In `useV2WorkspacePaneLayout.ts`, the store subscription should not create a +sidebar row. It should persist pane layout only if the row still exists: + +```ts +const existing = collections.v2WorkspaceLocalState.get(workspaceId); +if (!existing) { + return; +} + +collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.paneLayout = { + version: nextStore.version, + tabs: nextStore.tabs, + activeTabId: nextStore.activeTabId, + }; +}); +``` + +Initial insertion remains covered by the mount ensure effect in +`useV2WorkspacePaneLayout.ts:55` and by `v2-workspace/layout.tsx:61`, so this +does not prevent workspaces opened from outside the sidebar from being added. + +### Regression considerations for Part 1 + +- Memoizing `collections` by `activeOrganizationId` should be safe because + `getCollections(orgId)` is already org-scoped and cached internally. +- `switchOrganization` can still change when the active org changes or the + session refetch callback changes. +- Removing the store-subscription ensure preserves the current behavior that + opening a workspace ensures it in the sidebar, while preventing a removed + still-mounted workspace from resurrecting itself. +- After implementing the fixes, manually verify local and remote current-route + removal and non-current-route removal. + +### Verification run after code fixes + +- `bun run --cwd apps/desktop typecheck` +- `bunx @biomejs/biome@2.4.2 check ` + +Manual verification completed: + +- Manual: remove the currently viewed remote workspace; it should navigate away + and stay removed. +- Manual: remove the currently viewed local workspace; it should navigate away + and stay removed. +- Manual: open a v2 workspace from the all-workspaces list; it should still be + inserted into the sidebar. + +## Part 2 - sidebar re-render hardening + +The previous proposal said the sidebar live query re-emits when any field on +`v2WorkspaceLocalState` changes. I do not think that is true for the current +TanStack DB behavior. + +I verified this with a direct `@tanstack/db` live-query script matching the +sidebar query shape. A query that selected only: + +- `workspaceId` +- `sidebarState.projectId` +- `sidebarState.tabOrder` +- `sidebarState.sectionId` + +did **not** emit when these fields changed: + +- `paneLayout` +- `viewedFiles` +- `sidebarState.changesFilter` +- `sidebarState.changesSubtab` + +It did emit when `sidebarState.tabOrder` changed, and the full sidebar query +emits when joined host fields like `v2Hosts.isOnline` change. That is expected +because the sidebar query selects host online state. + +### Valid re-render sources + +These still look valid: + +1. **Host online-status updates.** `useDashboardSidebarData.ts:103` left-joins + `v2Hosts` and selects `hostIsOnline`. Any host ping that changes `isOnline` + should update the sidebar. +2. **PR refetch every 10s.** `useDashboardSidebarData.ts:148` refetches local + workspace PR data. `localPullRequestsByWorkspaceId` is rebuilt as a new + `Map` at line 177 whenever `pullRequestData?.workspaces` gets a new array. +3. **No memo barriers downstream.** `DashboardSidebarProjectSection` and + `DashboardSidebarWorkspaceItem` are not memoized, so a real `groups` change + can still fan out through the tree. +4. **Shortcut-label map identity.** `useDashboardSidebarShortcuts.ts:21` + returns a new `Map` when `flattenedWorkspaces` changes. If `groups` changes + for PR or host reasons, this can defeat memo boundaries. + +### Implemented low-risk hardening + +The implementation keeps the existing collection model and only stabilizes +derived data identities: + +1. `useDashboardSidebarData.ts` now keeps the local workspace id array stable + when the ids are unchanged, so PR refetch dependencies do not churn on equal + sidebar data. +2. `useDashboardSidebarData.ts` now reuses the local pull-request `Map` when + the refetched PR payload is structurally unchanged. +3. `useDashboardSidebarData.ts` now preserves unchanged project object + references after a real sidebar update, so updates in one project do not + force every project subtree to receive new props. +4. `useDashboardSidebarShortcuts.ts` now reuses the shortcut-label `Map` when + the first nine workspace ids are unchanged. +5. `DashboardSidebar.tsx` now memoizes `SortableProjectWrapper`, allowing + unchanged project rows to skip render work while still responding to dnd-kit + context changes. + +### Verification run after re-render hardening + +- `bun run --cwd apps/desktop typecheck` +- `bunx @biomejs/biome@2.4.2 check ` + +Still recommended manually: + +- Smoke test sidebar drag/reorder for projects, sections, and workspaces. +- Smoke test workspace shortcut labels and `Cmd+1` through `Cmd+9`. +- Use React DevTools Profiler while PR polling is active; unchanged project + rows should no longer receive new project props when the PR payload is equal. + +### Collection split recommendation + +Do **not** do the `v2WorkspaceLocalState` split as the fix for the stated +sidebar re-render issue based on the current evidence. + +The split may still be worth considering later for domain clarity, but it would +carry migration and callsite risk. It should require fresh profiling that proves +unrelated local-state writes currently invalidate the sidebar in production, not +just an assumption about row-level invalidation. + +### If a split is revisited, missed callsites + +The previous callsite list was incomplete. At minimum, also account for: + +- `useAccessibleV2Workspaces.ts:98` - left-joins local sidebar state to compute + `isInSidebar`. +- `ResourceConsumption.tsx:81` - reads sidebar workspace order. +- `getFlattenedV2WorkspaceIds.ts:7` - computes next workspace for navigation + after removal. +- `useDevSeedV2Sidebar.ts:26` - checks whether any sidebar workspace state + exists before dev seeding. +- `writeSidebarState.ts:130` and related tests - V1 migration writes combined + local state rows. +- `useDashboardSidebarState.ts` - most ordering, grouping, removal, and + cleanup logic reads or mutates the current combined collection. +- `GlobalTerminalLifecycle` and `GlobalBrowserLifecycle` - read all local rows + to detect pane removals. +- `V2NotificationController.tsx:52` - reads `paneLayout` for notification + targeting. +- `WorkspaceSidebar.tsx:74`, `useChangesTab.tsx:29`, and + `useSidebarDiffRef.ts:10` - read `changesSubtab` / `changesFilter`. +- `useViewedFiles.ts` and `useRecentlyViewedFiles.ts` - read and write local + workspace-page state. + +### Lower-risk hardening work + +Do these before any collection split: + +1. Stabilize `localPullRequestsByWorkspaceId` by content equality or use a + TanStack Query `select`/structural sharing strategy so equivalent refetches + preserve identity. +2. Stabilize `workspaceShortcutLabels` by returning the previous `Map` when the + workspace id/order labels are unchanged. +3. Add `React.memo` only after props are stable enough for it to be useful. +4. Profile with React DevTools before and after each change. Treat host online + status updates as legitimate sidebar updates unless the UI no longer needs + live online indicators. + +## Feedback summary + +The remote remove bug should be fixed with a small targeted patch, not the +collection split: + +1. Memoize `getCollections(activeOrganizationId)` by `activeOrganizationId`, or + return a fully cached object from `getCollections()`. +2. Remove `ensureWorkspaceInSidebar` from the pane-layout store subscription and + persist only when the local row still exists. +3. Keep the broad split out of this fix unless profiling demonstrates that + selected-field live queries actually emit on unrelated local-state writes in + the real app. + +## Files currently involved + +- `apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts` +- `apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts` +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts` diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index de8e33ee071..83198b01362 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -1,6 +1,10 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./utils/workspace-removal"; type CloseContext = { previousGrouped: ReturnType< @@ -13,6 +17,7 @@ type CloseContext = { >["workspaces"]["getAll"]["getData"] extends () => infer R ? R : never; + wasViewingClosed: boolean; }; /** @@ -31,6 +36,8 @@ export function useCloseWorkspace( return electronTrpc.workspaces.close.useMutation({ ...options, onMutate: async ({ id }) => { + const wasViewingClosed = params.workspaceId === id; + // Cancel outgoing refetches to avoid overwriting optimistic update await Promise.all([ utils.workspaces.getAll.cancel(), @@ -38,42 +45,38 @@ export function useCloseWorkspace( ]); // Snapshot previous values for rollback - const previousGrouped = utils.workspaces.getAllGrouped.getData(); + const previousGrouped = + utils.workspaces.getAllGrouped.getData() ?? + (wasViewingClosed + ? await utils.workspaces.getAllGrouped.fetch().catch((error) => { + console.warn( + "Failed to fetch grouped workspaces during close", + error, + ); + return undefined; + }) + : undefined); const previousAll = utils.workspaces.getAll.getData(); + // If the closed workspace is currently being viewed, navigate away using + // the pre-removal order. + if (wasViewingClosed) { + const targetWorkspaceId = getWorkspaceFocusTargetAfterRemoval( + previousGrouped, + id, + ); + if (targetWorkspaceId) { + navigateToWorkspace(targetWorkspaceId, navigate); + } else { + navigate({ to: "/workspace" }); + } + } + // Optimistically remove workspace from getAllGrouped cache if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, - previousGrouped - .map((group) => { - const isTopLevelWorkspace = group.workspaces.some( - (w) => w.id === id, - ); - const workspaces = group.workspaces.filter((w) => w.id !== id); - const sections = group.sections.map((section) => ({ - ...section, - workspaces: section.workspaces.filter((w) => w.id !== id), - })); - - return { - ...group, - workspaces, - sections, - topLevelItems: isTopLevelWorkspace - ? group.topLevelItems.filter((item) => item.id !== id) - : group.topLevelItems, - }; - }) - .filter( - (group) => - group.workspaces.length + - group.sections.reduce( - (sum, section) => sum + section.workspaces.length, - 0, - ) > - 0, - ), + removeWorkspaceFromGroups(previousGrouped, id), ); } @@ -86,9 +89,9 @@ export function useCloseWorkspace( } // Return context for rollback - return { previousGrouped, previousAll } as CloseContext; + return { previousGrouped, previousAll, wasViewingClosed } as CloseContext; }, - onError: (_err, _variables, context) => { + onError: async (err, variables, context, ...rest) => { // Rollback to previous state on error if (context?.previousGrouped !== undefined) { utils.workspaces.getAllGrouped.setData( @@ -99,6 +102,10 @@ export function useCloseWorkspace( if (context?.previousAll !== undefined) { utils.workspaces.getAll.setData(undefined, context.previousAll); } + if (context?.wasViewingClosed) { + navigateToWorkspace(variables.id, navigate); + } + await options?.onError?.(err, variables, context, ...rest); }, onSuccess: async (data, variables, ...rest) => { // Invalidate to ensure consistency with backend state @@ -106,27 +113,6 @@ export function useCloseWorkspace( // Invalidate project queries since close updates project metadata await utils.projects.getRecents.invalidate(); - // If the closed workspace is currently being viewed, navigate away - if (params.workspaceId === variables.id) { - // Try to navigate to previous workspace first, then next - const prevWorkspaceId = - await utils.workspaces.getPreviousWorkspace.fetch({ - id: variables.id, - }); - const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ - id: variables.id, - }); - - const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; - - if (targetWorkspaceId) { - navigateToWorkspace(targetWorkspaceId, navigate); - } else { - // No other workspaces, navigate to workspace index (shows StartView) - navigate({ to: "/workspace" }); - } - } - // Call user's onSuccess if provided await options?.onSuccess?.(data, variables, ...rest); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index a557cc18ee0..d2fde811115 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -1,6 +1,10 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./utils/workspace-removal"; type DeleteContext = { previousGrouped: ReturnType< @@ -14,7 +18,6 @@ type DeleteContext = { ? R : never; wasViewingDeleted: boolean; - navigatedTo: string | null; }; export function useDeleteWorkspace( @@ -28,65 +31,41 @@ export function useDeleteWorkspace( ...options, onMutate: async ({ id }) => { const wasViewingDeleted = params.workspaceId === id; - let navigatedTo: string | null = null; + + await Promise.all([ + utils.workspaces.getAll.cancel(), + utils.workspaces.getAllGrouped.cancel(), + ]); + + const previousGrouped = + utils.workspaces.getAllGrouped.getData() ?? + (wasViewingDeleted + ? await utils.workspaces.getAllGrouped.fetch().catch((error) => { + console.warn( + "Failed to fetch grouped workspaces during delete", + error, + ); + return undefined; + }) + : undefined); + const previousAll = utils.workspaces.getAll.getData(); if (wasViewingDeleted) { - const prevWorkspaceId = - await utils.workspaces.getPreviousWorkspace.fetch({ id }); - const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ + const targetWorkspaceId = getWorkspaceFocusTargetAfterRemoval( + previousGrouped, id, - }); - const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; - + ); if (targetWorkspaceId) { - navigatedTo = targetWorkspaceId; navigateToWorkspace(targetWorkspaceId, navigate); } else { - navigatedTo = "/workspace"; navigate({ to: "/workspace" }); } } - await Promise.all([ - utils.workspaces.getAll.cancel(), - utils.workspaces.getAllGrouped.cancel(), - ]); - - const previousGrouped = utils.workspaces.getAllGrouped.getData(); - const previousAll = utils.workspaces.getAll.getData(); - if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, - previousGrouped - .map((group) => { - const isTopLevelWorkspace = group.workspaces.some( - (w) => w.id === id, - ); - const workspaces = group.workspaces.filter((w) => w.id !== id); - const sections = group.sections.map((section) => ({ - ...section, - workspaces: section.workspaces.filter((w) => w.id !== id), - })); - - return { - ...group, - workspaces, - sections, - topLevelItems: isTopLevelWorkspace - ? group.topLevelItems.filter((item) => item.id !== id) - : group.topLevelItems, - }; - }) - .filter( - (group) => - group.workspaces.length + - group.sections.reduce( - (sum, section) => sum + section.workspaces.length, - 0, - ) > - 0, - ), + removeWorkspaceFromGroups(previousGrouped, id), ); } @@ -101,7 +80,6 @@ export function useDeleteWorkspace( previousGrouped, previousAll, wasViewingDeleted, - navigatedTo, } as DeleteContext; }, onSettled: async (...args) => { diff --git a/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts new file mode 100644 index 00000000000..c47a637a6e0 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./workspace-removal"; + +const groups = [ + { + workspaces: [ + { id: "w1", tabOrder: 1 }, + { id: "w4", tabOrder: 4 }, + ], + sections: [ + { + id: "s1", + tabOrder: 2, + workspaces: [ + { id: "w2", tabOrder: 1 }, + { id: "w3", tabOrder: 2 }, + ], + }, + ], + topLevelItems: [ + { id: "w1", kind: "workspace" as const, tabOrder: 1 }, + { id: "s1", kind: "section" as const, tabOrder: 2 }, + { id: "w4", kind: "workspace" as const, tabOrder: 3 }, + ], + }, +]; + +describe("getWorkspaceFocusTargetAfterRemoval", () => { + it("selects next, then previous, in sidebar visual order", () => { + expect(getWorkspaceFocusTargetAfterRemoval(groups, "w2")).toBe("w3"); + expect(getWorkspaceFocusTargetAfterRemoval(groups, "w4")).toBe("w3"); + expect( + getWorkspaceFocusTargetAfterRemoval( + [ + { + workspaces: [{ id: "w1", tabOrder: 1 }], + sections: [], + topLevelItems: [ + { id: "w1", kind: "workspace" as const, tabOrder: 1 }, + ], + }, + ], + "w1", + ), + ).toBeNull(); + }); +}); + +describe("removeWorkspaceFromGroups", () => { + it("removes section and top-level workspaces", () => { + expect( + removeWorkspaceFromGroups(groups, "w2")[0]?.sections[0]?.workspaces.map( + (workspace) => workspace.id, + ), + ).toEqual(["w3"]); + expect( + removeWorkspaceFromGroups(groups, "w4")[0]?.topLevelItems.map( + (item) => item.id, + ), + ).toEqual(["w1", "s1"]); + }); +}); diff --git a/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts new file mode 100644 index 00000000000..f9cceda9e1e --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts @@ -0,0 +1,105 @@ +import { getActiveIdAfterRemoval } from "@superset/panes"; + +type WorkspaceLike = { + id: string; + tabOrder: number; +}; + +type SectionLike = { + id: string; + workspaces: WorkspaceLike[]; +}; + +type TopLevelItemLike = { + id: string; + kind: "workspace" | "section"; + tabOrder: number; +}; + +type WorkspaceGroupLike = { + workspaces: WorkspaceLike[]; + sections: SectionLike[]; + topLevelItems: TopLevelItemLike[]; +}; + +function compareTopLevelItems( + left: TopLevelItemLike, + right: TopLevelItemLike, +): number { + return ( + left.tabOrder - right.tabOrder || + (left.kind === right.kind ? 0 : left.kind === "section" ? -1 : 1) + ); +} + +function hasVisibleWorkspaces(group: WorkspaceGroupLike): boolean { + return ( + group.workspaces.length > 0 || + group.sections.some((section) => section.workspaces.length > 0) + ); +} + +function getWorkspaceIdsFromGroups( + groups: readonly WorkspaceGroupLike[] | undefined, +): string[] { + return (groups ?? []).flatMap((group) => + [...group.topLevelItems].sort(compareTopLevelItems).flatMap((item) => { + if (item.kind === "workspace") { + return group.workspaces.some((workspace) => workspace.id === item.id) + ? [item.id] + : []; + } + + const section = group.sections.find((section) => section.id === item.id); + return section + ? [...section.workspaces] + .sort((left, right) => left.tabOrder - right.tabOrder) + .map((workspace) => workspace.id) + : []; + }), + ); +} + +export function getWorkspaceFocusTargetAfterRemoval( + groups: readonly WorkspaceGroupLike[] | undefined, + removedWorkspaceId: string, +): string | null { + return getActiveIdAfterRemoval( + getWorkspaceIdsFromGroups(groups), + removedWorkspaceId, + removedWorkspaceId, + ); +} + +export function removeWorkspaceFromGroups( + groups: readonly TGroup[], + workspaceId: string, +): TGroup[] { + return groups + .map((group) => { + const isTopLevelWorkspace = group.workspaces.some( + (workspace) => workspace.id === workspaceId, + ); + const workspaces = group.workspaces.filter( + (workspace) => workspace.id !== workspaceId, + ); + // Keep empty sections: getAllGrouped returns user-created sections even + // when they have no workspaces, so the optimistic cache should match. + const sections = group.sections.map((section) => ({ + ...section, + workspaces: section.workspaces.filter( + (workspace) => workspace.id !== workspaceId, + ), + })); + + return { + ...group, + workspaces, + sections, + topLevelItems: isTopLevelWorkspace + ? group.topLevelItems.filter((item) => item.id !== workspaceId) + : group.topLevelItems, + } as TGroup; + }) + .filter(hasVisibleWorkspaces); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx index c8fe15af692..5696520e8ff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx @@ -105,7 +105,7 @@ function AutomationsPage() { ), }); - const { data: automationRows = [] } = useLiveQuery( + const { data: automationRows = [], isReady: automationsReady } = useLiveQuery( (q) => q .from({ a: collections.automations }) @@ -228,7 +228,7 @@ function AutomationsPage() {
- {automations.length === 0 ? ( + {!automationsReady ? null : automations.length === 0 ? ( ) : ( <> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index e48f89e7a02..8d2482cd569 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -18,7 +18,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; @@ -33,21 +33,23 @@ interface DashboardSidebarProps { isCollapsed?: boolean; } -function SortableProjectWrapper({ - project, - isCollapsed, - isDraggingProject, - workspaceShortcutLabels, - onWorkspaceHover, - onToggleCollapse, -}: { +interface SortableProjectWrapperProps { project: DashboardSidebarProject; isCollapsed: boolean; isDraggingProject: boolean; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; -}) { +} + +const SortableProjectWrapper = memo(function SortableProjectWrapper({ + project, + isCollapsed, + isDraggingProject, + workspaceShortcutLabels, + onWorkspaceHover, + onToggleCollapse, +}: SortableProjectWrapperProps) { const { attributes, listeners, @@ -78,7 +80,7 @@ function SortableProjectWrapper({ />
); -} +}); export function DashboardSidebar({ isCollapsed = false, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx index 8eaef9e8d38..c9402725dae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx @@ -1,21 +1,28 @@ +import { COMPANY } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { LuChevronRight, LuCircleAlert, LuRadioTower } from "react-icons/lu"; +import { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; import { usePortsStore } from "renderer/stores"; import { DashboardSidebarPortGroup } from "./components/DashboardSidebarPortGroup"; import { useDashboardSidebarPortsData } from "./hooks/useDashboardSidebarPortsData"; +const PORTS_DOCS_URL = `${COMPANY.DOCS_URL}/ports`; + export function DashboardSidebarPortsList() { const isCollapsed = usePortsStore((state) => state.isListCollapsed); const toggleCollapsed = usePortsStore((state) => state.toggleListCollapsed); - const { totalPortCount, workspacePortGroups, portLoadErrors } = + const { totalPortCount, workspacePortGroups } = useDashboardSidebarPortsData(); - const failedHostCount = portLoadErrors.length; - if (totalPortCount === 0 && failedHostCount === 0) { + if (totalPortCount === 0) { return null; } + const handleOpenPortsDocs = (e: React.MouseEvent) => { + e.stopPropagation(); + window.open(PORTS_DOCS_URL, "_blank"); + }; + return (
@@ -38,35 +45,21 @@ export function DashboardSidebarPortsList() { Ports - {failedHostCount > 0 && ( - - - - - - - -

- {failedHostCount === 1 - ? "Could not load ports from 1 host" - : `Could not load ports from ${failedHostCount} hosts`} -

-
-
- )} - 0 - ? "text-[10px] font-normal" - : "ml-auto text-[10px] font-normal" - } - > - {totalPortCount} - + + + + + +

Learn about port labels

+
+
+ {totalPortCount}
{!isCollapsed && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index f01141918cd..85533f43c23 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -44,8 +44,10 @@ export function DashboardSidebarWorkspaceItem({ handleCreateSection, handleDeleted, handleOpenInFinder, + handleToggleUnread, isActive, isDeleteDialogOpen, + isUnread, isRenaming, moveWorkspaceToSection, removeWorkspaceFromSidebar, @@ -105,6 +107,7 @@ export function DashboardSidebarWorkspaceItem({ removeWorkspaceFromSidebar(id)} onRename={startRename} onDelete={() => setIsDeleteDialogOpen(true)} + onToggleUnread={handleToggleUnread} > {content} @@ -169,6 +173,7 @@ export function DashboardSidebarWorkspaceItem({ removeWorkspaceFromSidebar(id)} onRename={startRename} onDelete={() => setIsDeleteDialogOpen(true)} + onToggleUnread={handleToggleUnread} > {expandedContent} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index c4b799efa0c..e1b4dcad459 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -20,6 +20,8 @@ import { LuArrowRightLeft, LuArrowUp, LuCopy, + LuEye, + LuEyeOff, LuFolderOpen, LuFolderPlus, LuGitBranch, @@ -34,6 +36,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { projectId: string; isInSection?: boolean; isLocalWorkspace: boolean; + isUnread: boolean; onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; @@ -43,6 +46,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { onRemoveFromSidebar: () => void; onRename: () => void; onDelete: () => void; + onToggleUnread: () => void; children: React.ReactNode; } @@ -50,6 +54,7 @@ export function DashboardSidebarWorkspaceContextMenu({ projectId, isInSection, isLocalWorkspace, + isUnread, onHoverCardOpen, hoverCardContent, onCreateSection, @@ -60,6 +65,7 @@ export function DashboardSidebarWorkspaceContextMenu({ onRemoveFromSidebar, onRename, onDelete, + onToggleUnread, children, }: DashboardSidebarWorkspaceContextMenuProps) { const collections = useCollections(); @@ -105,6 +111,20 @@ export function DashboardSidebarWorkspaceContextMenu({ Copy Branch Name + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + New group from workspace diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 5957d04a5ef..f79b7b6fb60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,3 +1,4 @@ +import { getActiveIdAfterRemoval } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; @@ -5,13 +6,16 @@ import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; -import { getDeleteFocusTargetWorkspaceId } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId"; import { getFlattenedV2WorkspaceIds } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + useV2NotificationStore, + useV2WorkspaceIsUnread, +} from "renderer/stores/v2-notifications"; interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; @@ -33,6 +37,11 @@ export function useDashboardSidebarWorkspaceItemActions({ const { copyToClipboard } = useCopyToClipboard(); const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions(); const { requestSectionRename } = useDashboardSidebarSectionRename(); + const clearWorkspaceAttention = useV2NotificationStore( + (s) => s.clearWorkspaceAttention, + ); + const setManualUnread = useV2NotificationStore((s) => s.setManualUnread); + const isUnread = useV2WorkspaceIsUnread(workspaceId); const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } = useDashboardSidebarState(); @@ -48,6 +57,7 @@ export function useDashboardSidebarWorkspaceItemActions({ const handleClick = () => { if (isRenaming) return; + clearWorkspaceAttention(workspaceId); navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId }, @@ -78,9 +88,10 @@ export function useDashboardSidebarWorkspaceItemActions({ */ const handleDeleted = () => { const focusTargetId = isActive - ? getDeleteFocusTargetWorkspaceId( + ? getActiveIdAfterRemoval( getFlattenedV2WorkspaceIds(collections), workspaceId, + workspaceId, ) : null; @@ -140,6 +151,14 @@ export function useDashboardSidebarWorkspaceItemActions({ } }; + const handleToggleUnread = () => { + if (isUnread) { + clearWorkspaceAttention(workspaceId); + } else { + setManualUnread(workspaceId); + } + }; + const handleCopyBranchName = async () => { if (!branch) { toast.error("Branch name is not available"); @@ -163,9 +182,11 @@ export function useDashboardSidebarWorkspaceItemActions({ handleCreateSection, handleDeleted, handleOpenInFinder, + handleToggleUnread, isActive, isDeleteDialogOpen, isRenaming, + isUnread, moveWorkspaceToSection, removeWorkspaceFromSidebar, renameValue, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 4f915b32ae2..7146ad215ce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -1,7 +1,7 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -20,6 +20,127 @@ import type { // which is inserted via getPrependTabOrder. const PENDING_WORKSPACE_TAB_ORDER = Number.MIN_SAFE_INTEGER; +type LocalPullRequest = DashboardSidebarWorkspace["pullRequest"]; +type PullRequestWorkspaceRow = { + workspaceId: string; + pullRequest: LocalPullRequest; +}; + +function haveSameStrings(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function haveSameProjects( + left: DashboardSidebarProject[], + right: DashboardSidebarProject[], +): boolean { + return ( + left.length === right.length && + left.every((project, index) => project === right[index]) + ); +} + +function getPullRequestRowsFingerprint( + rows: PullRequestWorkspaceRow[], +): string { + return JSON.stringify( + rows + .map((row) => [row.workspaceId, row.pullRequest] as const) + .sort(([leftWorkspaceId], [rightWorkspaceId]) => + leftWorkspaceId.localeCompare(rightWorkspaceId), + ), + ); +} + +function getDashboardSidebarProjectFingerprint( + project: DashboardSidebarProject, +): string { + return JSON.stringify(project); +} + +function useStableStringArray(values: string[]): string[] { + const previousRef = useRef(null); + + return useMemo(() => { + const previous = previousRef.current; + if (previous && haveSameStrings(previous, values)) { + return previous; + } + + previousRef.current = values; + return values; + }, [values]); +} + +function useStableLocalPullRequestsByWorkspaceId( + rows: PullRequestWorkspaceRow[] | undefined, +): Map { + const previousRef = useRef<{ + fingerprint: string; + map: Map; + } | null>(null); + + return useMemo(() => { + const nextRows = rows ?? []; + const fingerprint = getPullRequestRowsFingerprint(nextRows); + const previous = previousRef.current; + if (previous?.fingerprint === fingerprint) { + return previous.map; + } + + const map = new Map( + nextRows.map((workspace) => [ + workspace.workspaceId, + workspace.pullRequest, + ]), + ); + previousRef.current = { fingerprint, map }; + return map; + }, [rows]); +} + +function useStableDashboardSidebarProjects( + projects: DashboardSidebarProject[], +): DashboardSidebarProject[] { + const previousRef = useRef<{ + projects: DashboardSidebarProject[]; + byId: Map< + string, + { fingerprint: string; project: DashboardSidebarProject } + >; + } | null>(null); + + return useMemo(() => { + const previous = previousRef.current; + const nextById = new Map< + string, + { fingerprint: string; project: DashboardSidebarProject } + >(); + const nextProjects = projects.map((project) => { + const fingerprint = getDashboardSidebarProjectFingerprint(project); + const previousProject = previous?.byId.get(project.id); + const stableProject = + previousProject?.fingerprint === fingerprint + ? previousProject.project + : project; + + nextById.set(project.id, { fingerprint, project: stableProject }); + return stableProject; + }); + + if (previous && haveSameProjects(previous.projects, nextProjects)) { + previousRef.current = { projects: previous.projects, byId: nextById }; + return previous.projects; + } + + previousRef.current = { projects: nextProjects, byId: nextById }; + return nextProjects; + }, [projects]); +} + export function useDashboardSidebarData() { const { data: session } = authClient.useSession(); const collections = useCollections(); @@ -132,7 +253,7 @@ export function useDashboardSidebarData() { [collections], ); - const localWorkspaceIds = useMemo( + const computedLocalWorkspaceIds = useMemo( () => sidebarWorkspaces .filter( @@ -144,6 +265,7 @@ export function useDashboardSidebarData() { .sort(), [machineId, sidebarWorkspaces], ); + const localWorkspaceIds = useStableStringArray(computedLocalWorkspaceIds); const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ queryKey: [ @@ -174,18 +296,10 @@ export function useDashboardSidebarData() { [activeHostClient, localWorkspaceIds, refetchPullRequests], ); - const localPullRequestsByWorkspaceId = useMemo( - () => - new Map( - (pullRequestData?.workspaces ?? []).map((workspace) => [ - workspace.workspaceId, - workspace.pullRequest, - ]), - ), - [pullRequestData?.workspaces], - ); + const localPullRequestsByWorkspaceId = + useStableLocalPullRequestsByWorkspaceId(pullRequestData?.workspaces); - const groups = useMemo(() => { + const computedGroups = useMemo(() => { const projectsById = new Map< string, DashboardSidebarProject & { @@ -366,6 +480,7 @@ export function useDashboardSidebarData() { sidebarSections, sidebarWorkspaces, ]); + const groups = useStableDashboardSidebarProjects(computedGroups); return { groups, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index ae1947b96a0..7660739882c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -1,5 +1,5 @@ import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import type { DashboardSidebarProject } from "../../types"; @@ -7,6 +7,38 @@ import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; const MAX_SHORTCUT_COUNT = 9; +function haveSameIds(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) + ); +} + +function useStableWorkspaceShortcutLabels( + workspaces: Array<{ id: string }>, +): Map { + const previousRef = useRef<{ + workspaceIds: string[]; + labels: Map; + } | null>(null); + + return useMemo(() => { + const workspaceIds = workspaces + .slice(0, MAX_SHORTCUT_COUNT) + .map((workspace) => workspace.id); + const previous = previousRef.current; + if (previous && haveSameIds(previous.workspaceIds, workspaceIds)) { + return previous.labels; + } + + const labels = new Map( + workspaceIds.map((workspaceId, index) => [workspaceId, `⌘${index + 1}`]), + ); + previousRef.current = { workspaceIds, labels }; + return labels; + }, [workspaces]); +} + export function useDashboardSidebarShortcuts( groups: DashboardSidebarProject[], ) { @@ -18,15 +50,8 @@ export function useDashboardSidebarShortcuts( .filter((workspace) => !workspace.creationStatus), [groups], ); - const workspaceShortcutLabels = useMemo( - () => - new Map( - flattenedWorkspaces - .slice(0, MAX_SHORTCUT_COUNT) - .map((workspace, index) => [workspace.id, `⌘${index + 1}`]), - ), - [flattenedWorkspaces], - ); + const workspaceShortcutLabels = + useStableWorkspaceShortcutLabels(flattenedWorkspaces); const switchToWorkspace = useCallback( (index: number) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts index 6915878a0f6..6a795a6f372 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts @@ -1,4 +1,5 @@ -import { useNavigate, useParams } from "@tanstack/react-router"; +import { getActiveIdAfterRemoval } from "@superset/panes"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getFlattenedV2WorkspaceIds } from "../../utils/getFlattenedV2WorkspaceIds"; @@ -11,13 +12,18 @@ import { getFlattenedV2WorkspaceIds } from "../../utils/getFlattenedV2WorkspaceI */ export function useNavigateAwayFromWorkspace() { const navigate = useNavigate(); - const params = useParams({ strict: false }); + const matchRoute = useMatchRoute(); const collections = useCollections(); return (workspaceId: string) => { - if (params.workspaceId !== workspaceId) return; + const isViewingWorkspace = !!matchRoute({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + fuzzy: true, + }); + if (!isViewingWorkspace) return; const ids = getFlattenedV2WorkspaceIds(collections); - const next = ids.find((id) => id !== workspaceId); + const next = getActiveIdAfterRemoval(ids, workspaceId, workspaceId); if (next) { void navigateToV2Workspace(next, navigate); } else { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/getDeleteFocusTargetWorkspaceId.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/getDeleteFocusTargetWorkspaceId.ts deleted file mode 100644 index 3ae3971fb90..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/getDeleteFocusTargetWorkspaceId.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function getDeleteFocusTargetWorkspaceId( - flattenedWorkspaceIds: readonly string[], - deletedWorkspaceId: string, -): string | null { - const index = flattenedWorkspaceIds.indexOf(deletedWorkspaceId); - if (index === -1) return null; - return ( - flattenedWorkspaceIds[index - 1] ?? flattenedWorkspaceIds[index + 1] ?? null - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/index.ts deleted file mode 100644 index dffd3b0fba9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getDeleteFocusTargetWorkspaceId } from "./getDeleteFocusTargetWorkspaceId"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx index e928d439695..50df503d2d7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -58,6 +58,7 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { const adoptWorktree = useAdoptWorktree(); const trpcUtils = electronTrpc.useUtils(); const { activeHostUrl } = useLocalHostService(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); const fire = useCallback(async () => { if (!pending) return; @@ -144,6 +145,15 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { } } + // Register in the sidebar as soon as the workspace exists. The + // post-create navigate effect also calls this, but only fires while + // the user is still on the pending page and after workspace sync + // completes — calling it here guarantees the row appears even if the + // user has navigated away or sync is slow. + if (result.workspace?.id) { + ensureWorkspaceInSidebar(result.workspace.id, pending.projectId); + } + // V2 dispatch: after host-service.create resolves, build the launch // plan and stash it on the pending row. The V2 workspace page's // useConsumePendingLaunch mount-effect picks it up and opens the @@ -197,6 +207,7 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { createWorkspace, checkoutWorkspace, adoptWorktree, + ensureWorkspaceInSidebar, pending, pendingId, trpcUtils, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx index 6f1c8a7644a..7f1fa455d74 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx @@ -1,12 +1,6 @@ import { Checkbox } from "@superset/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { - ChevronDown, - ChevronRight, - ExternalLink, - Eye, - EyeOff, -} from "lucide-react"; +import { ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react"; import { useId } from "react"; import { LuCopy, LuUndo2 } from "react-icons/lu"; import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; @@ -78,62 +72,34 @@ export function DiffFileHeader({ }} disabled={!onOpenFile && !onOpenInExternalEditor} aria-label="Open in file viewer" - className="flex h-6 min-w-20 flex-[1_1_10rem] items-center gap-1.5 rounded border border-border px-1.5 py-0.5 text-left transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-60" + className="flex h-6 min-w-0 items-center gap-1.5 rounded border border-border px-1.5 py-0.5 text-left transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-60" > - + {path} - - {additions > 0 && ( - - +{additions} - - )} - {additions > 0 && deletions > 0 && " "} - {deletions > 0 && ( - - -{deletions} - - )} - - -
-
Open in file viewer. {CLICK_HINT_TOOLTIP}
-
- {path} -
-
+ + {CLICK_HINT_TOOLTIP} - -
- - - - - - - - {onOpenInExternalEditor - ? "Open in editor" - : "Open in editor unavailable"} - - +
+ {(additions > 0 || deletions > 0) && ( + + {additions > 0 && ( + + +{additions} + + )} + {additions > 0 && deletions > 0 && " "} + {deletions > 0 && ( + + -{deletions} + + )} + + )}
- {isDropActive && ( -
- )}
+
{connectionState === "closed" && (
Disconnected diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 25cc4229f78..3da9cae0d1d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -1,5 +1,4 @@ import type { RendererContext } from "@superset/panes"; -import { alert } from "@superset/ui/atoms/Alert"; import { DropdownMenu, DropdownMenuContent, @@ -34,7 +33,6 @@ interface TerminalSessionDropdownProps { interface VisibleTerminalSession { terminalId: string; - workspaceId: string; createdAt?: number; exited: boolean; exitCode: number; @@ -84,8 +82,7 @@ export function TerminalSessionDropdown({ workspaceId, }: TerminalSessionDropdownProps) { const [isOpen, setIsOpen] = useState(false); - const data = context.pane.data as TerminalPaneData; - const { terminalId } = data; + const { terminalId } = context.pane.data as TerminalPaneData; const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); @@ -99,22 +96,26 @@ export function TerminalSessionDropdown({ const sessions = useMemo(() => { const liveSessions = sessionsQuery.data?.sessions ?? []; - if (liveSessions.some((session) => session.terminalId === terminalId)) { - return liveSessions; + const ordered = [...liveSessions].sort((a, b) => { + if (a.terminalId === terminalId) return -1; + if (b.terminalId === terminalId) return 1; + return (b.createdAt ?? 0) - (a.createdAt ?? 0); + }); + if (ordered.some((session) => session.terminalId === terminalId)) { + return ordered; } return [ { terminalId, - workspaceId, exited: false, exitCode: 0, attached: false, title: null, pending: true, }, - ...liveSessions, + ...ordered, ]; - }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); + }, [sessionsQuery.data?.sessions, terminalId]); const currentSession = sessions.find( (session) => session.terminalId === terminalId, ); @@ -136,7 +137,8 @@ export function TerminalSessionDropdown({ ? getTerminalPaneLocations(context) : EMPTY_TERMINAL_PANE_LOCATIONS; - const handleSelectSession = (nextTerminalId: string) => { + const handleSelectSession = (session: VisibleTerminalSession) => { + const nextTerminalId = session.terminalId; if (nextTerminalId === terminalId) { setIsOpen(false); return; @@ -145,18 +147,30 @@ export function TerminalSessionDropdown({ const state = context.store.getState(); const terminalPaneLocations = getTerminalPaneLocations(context); const existingLocation = terminalPaneLocations.get(nextTerminalId)?.[0]; + if (existingLocation) { + state.setActiveTab(existingLocation.tabId); + state.setActivePane({ + tabId: existingLocation.tabId, + paneId: existingLocation.paneId, + }); + setIsOpen(false); + return; + } + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { markTerminalForBackground(terminalId); } state.setPaneData({ paneId: context.pane.id, - data: { terminalId: nextTerminalId } as PaneViewerData, + data: { + terminalId: nextTerminalId, + } as PaneViewerData, }); state.setPaneTitleOverride({ tabId: context.tab.id, paneId: context.pane.id, - titleOverride: existingLocation?.titleOverride, + titleOverride: undefined, }); setIsOpen(false); }; @@ -175,34 +189,23 @@ export function TerminalSessionDropdown({ } }; - const removeTerminalSession = async (targetTerminalId: string) => { - await killTerminalSession.mutateAsync({ - terminalId: targetTerminalId, - workspaceId, - }); - closePanesForTerminal(targetTerminalId); - await utils.terminal.listSessions.invalidate({ workspaceId }); + const removeTerminalSession = async (session: VisibleTerminalSession) => { + try { + await killTerminalSession.mutateAsync({ + terminalId: session.terminalId, + workspaceId, + }); + closePanesForTerminal(session.terminalId); + } finally { + await utils.terminal.listSessions.invalidate({ workspaceId }); + } }; - const handleRemoveTerminal = (targetTerminalId: string) => { - alert({ - title: "Remove terminal session?", - description: - "This will terminate the underlying process. Use Move terminal to background to keep it running without a pane.", - actions: [ - { label: "Cancel", variant: "outline", onClick: () => {} }, - { - label: "Remove Terminal", - variant: "destructive", - onClick: () => { - toast.promise(removeTerminalSession(targetTerminalId), { - loading: "Removing terminal...", - success: "Terminal removed", - error: "Failed to remove terminal", - }); - }, - }, - ], + const handleRemoveTerminal = (session: VisibleTerminalSession) => { + toast.promise(removeTerminalSession(session), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", }); }; @@ -255,8 +258,21 @@ export function TerminalSessionDropdown({ - - Terminal Sessions + + Terminal Sessions +
@@ -283,7 +299,7 @@ export function TerminalSessionDropdown({ key={session.terminalId} className="group flex items-center gap-2" onSelect={(_event) => { - handleSelectSession(session.terminalId); + handleSelectSession(session); }} > @@ -306,7 +322,7 @@ export function TerminalSessionDropdown({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - handleRemoveTerminal(session.terminalId); + handleRemoveTerminal(session); }} > @@ -320,11 +336,6 @@ export function TerminalSessionDropdown({
)}
- - - - New Terminal - ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts index 088c332ccda..cd9577a4a0d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts @@ -77,7 +77,6 @@ export function useV2WorkspacePaneLayout({ return; } - ensureWorkspaceInSidebar(workspaceId, projectId); if (!collections.v2WorkspaceLocalState.get(workspaceId)) { return; } @@ -95,7 +94,7 @@ export function useV2WorkspacePaneLayout({ return () => { unsubscribe(); }; - }, [collections, ensureWorkspaceInSidebar, projectId, store, workspaceId]); + }, [collections, store, workspaceId]); return { localWorkspaceState, diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts index b78b8618336..fcbb5f6657f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts @@ -1,4 +1,4 @@ -import type { TaskPriority } from "@superset/db/enums"; +import type { TaskPriority, V2UsersHostRole } from "@superset/db/enums"; import { toast } from "@superset/ui/sonner"; import { useCallback, useMemo } from "react"; import { isDesktopChatDevMode } from "renderer/lib/dev-chat"; @@ -94,6 +94,11 @@ export function useOptimisticCollectionActions() { mutation: () => PersistableTransaction, ) => runMutation("optimistic.chatSessions", failureTitle, mutation); + const runUsersHostsMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2UsersHosts", failureTitle, mutation); + return { tasks: { updateTitle: (taskId: string, title: string) => @@ -197,6 +202,36 @@ export function useOptimisticCollectionActions() { ); }, }, + v2UsersHosts: { + addMember: (input: { + hostId: string; + userId: string; + organizationId: string; + role?: V2UsersHostRole; + }) => + runUsersHostsMutation("Failed to add member", () => { + const now = new Date(); + return collections.v2UsersHosts.insert({ + id: crypto.randomUUID(), + hostId: input.hostId, + userId: input.userId, + organizationId: input.organizationId, + role: input.role ?? "member", + createdAt: now, + updatedAt: now, + }); + }), + removeMember: (rowId: string) => + runUsersHostsMutation("Failed to remove member", () => + collections.v2UsersHosts.delete(rowId), + ), + setMemberRole: (rowId: string, role: V2UsersHostRole) => + runUsersHostsMutation("Failed to update role", () => + collections.v2UsersHosts.update(rowId, (draft) => { + draft.role = role; + }), + ), + }, }; }, [collections, runMutation]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index ac8dcc86c13..f5562295086 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, + useMemo, useState, } from "react"; import { env } from "renderer/env.renderer"; @@ -55,16 +56,22 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { preloadActiveOrganizationCollections(activeOrganizationId); }, [activeOrganizationId]); - const collections = activeOrganizationId - ? getCollections(activeOrganizationId) - : null; + const collections = useMemo( + () => (activeOrganizationId ? getCollections(activeOrganizationId) : null), + [activeOrganizationId], + ); + + const contextValue = useMemo( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], + ); - if (!collections || isSwitching) { + if (!contextValue || isSwitching) { return null; } return ( - + {children} ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index b9841717a7d..03a9b45f119 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -335,6 +335,36 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const item = transaction.mutations[0].modified; + const result = await apiClient.v2Host.addMember.mutate({ + id: item.id, + hostId: item.hostId, + userId: item.userId, + role: item.role, + }); + return { txid: result.txid }; + }, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + if (changes.role === undefined) { + throw new Error("Only role updates are supported on v2_users_hosts"); + } + const result = await apiClient.v2Host.setMemberRole.mutate({ + hostId: original.hostId, + userId: original.userId, + role: changes.role, + }); + return { txid: result.txid }; + }, + onDelete: async ({ transaction }) => { + const item = transaction.mutations[0].original; + const result = await apiClient.v2Host.removeMember.mutate({ + hostId: item.hostId, + userId: item.userId, + }); + return { txid: result.txid }; + }, }), ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index a52b870c212..a2df833e846 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -5,9 +5,11 @@ import { HiOutlineBuildingOffice2, HiOutlineChartBar, HiOutlineCommandLine, + HiOutlineComputerDesktop, HiOutlineCpuChip, HiOutlineCreditCard, HiOutlineExclamationCircle, + HiOutlineFolder, HiOutlineKey, HiOutlineLink, HiOutlineLockClosed, @@ -48,7 +50,9 @@ type SettingsRoute = | "/settings/api-keys" | "/settings/metrics" | "/settings/security" - | "/settings/permissions"; + | "/settings/permissions" + | "/settings/projects" + | "/settings/hosts"; interface SectionItem { id: SettingsRoute; @@ -161,6 +165,18 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Organization", icon: , }, + { + id: "/settings/projects", + section: "project", + label: "Projects", + icon: , + }, + { + id: "/settings/hosts", + section: "hosts", + label: "Hosts", + icon: , + }, { id: "/settings/integrations", section: "integrations", @@ -237,7 +253,10 @@ export function GeneralSettings({ matchCounts }: GeneralSettingsProps) {