feat(workspaces): add workspace labels (#3510)
* feat(app): workspace labels baseline Existing labels implementation, committed as-is as the baseline for the UI redesign. Server, protocol, catalog store and sync are sound; the UI layer is reworked in the commits that follow. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): show workspace labels on the sidebar meta line Labels could be assigned and then appeared nowhere. They now draw as a chip on the workspace meta row, after the host, change request, checks and service, and they can be switched off from the Show submenu like any other item on that line. One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind 500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set identity-colors.ts was written to replace. Label color is now the identity color of the same name, so a chip, a host badge and a CI check on one row sit in one contrast band. `workspace-labels/colors.ts` and its per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx` are the only places a label color becomes ink. A row resolves its labels against its own host's catalog and nothing else. The cross-host projection answers too, but two hosts may give one name two colors and the merge settles that by target precedence and then alphabetical serverId — a row painted in a color its host never assigned is lying about the one thing the color is for. The meta row's doc comment argued against tinted chrome on that line. The chip is a deliberate exception — everything else there reports state and shares the state colors, a label is a name a person chose — so the comment now says that instead of contradicting the code. Alignment: - The chip is exactly as tall as the line: text at the row's 16pt lineHeight, no vertical padding, so a labelled row is the same height as an unlabelled one. - A label run that opens the line hangs left by the chip's own padding, landing the name on the rail the title above it uses. Mid-line the ground stays in the flow. - The swatch row reaches 44pt through hitSlop growing outward from a 20pt swatch, replacing ten 44pt colored blobs in the manager modal. - The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through withUnistyles with module-level mappings, at the menu's 14pt. Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict object, so adding an item without a key there fails the whole settings write and silently loses every other Show toggle with it. A test now covers each item in SIDEBAR_ROW_ITEMS. Phase 1 of the workspace labels redesign. The filter menu, the picker's interaction model and the manager modal's layout are later phases. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): one Labels filter page with tri-state rows Filtering by label cost three levels: Display -> Labels -> Include -> a page of labels, or -> Exclude -> the same page again. It is now one page: every label across every host, one row each, leading with its colour dot. A row cycles off -> include -> exclude -> off. Include is MenuItem's own check; exclude is lucide's X, the check's own pair, at the same 16 in the same trailing slot, so the column does not shift as a row cycles. Exclude also dims the row, because a state you have to identify by its glyph is not one you read at a glance. Unlabelled is a row in the same list, with a hollow ring where the others carry their colour. Match any / all still appears only once two labels are included. Clear is absent, not disabled, when nothing is filtered. Manage labels sits at the bottom below a separator. The whole root row is gone when no host has a label — unless a filter is still active, since the merged catalog only counts online hosts and losing one would otherwise strand a filter that is still hiding workspaces. The page is titled Labels, so its rows stop repeating the word: "Match any", "Match all", "Clear filter". The two Match rows sit directly under the list they qualify and only appear once two labels are included, so the subject is already on screen. All nine locales move together, translated rather than transliterated — Arabic keeps its noun, where "أي" is a determiner that needs one to stay grammatical. SidebarLabelFilter collapses from four collections plus match to one Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one value per label, so include and exclude can no longer disagree about the same name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName makes unreachable for a real label. toggleSidebarLabelSelection, countSidebarLabelSelections, sidebarLabelSelectionIncludes and deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes cycleLabelFilter and setLabelMatch, so the store owns its own transitions. No migration: labels are unreleased, so the old persisted shape never shipped, and migrateSidebarViewState already resets on a safeParse failure. The e2e manager step asserted "Red label color" on a swatch that Phase 1 renamed to "Red"; the selector follows the code, and the accessibility.color key behind the old string is deleted from all nine locales as unreferenced. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): assign labels on the menu's own rails The assign picker was a search box, a create row that unfolded an inline form under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and name off the rail the rows above them sat on. Nothing lined up with anything. Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as the trailing check the engine already draws for a chosen row. Using the engine's row is the alignment fix; there is no second row implementation to keep in step. Every press toggles and the menu stays open — assigning labels is multi-select, so the dual-target model (`source: "row" | "checkbox"`, `shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close` dependency. You leave by dismissing the menu. Search is deleted. Nobody has enough labels to search, and the field was the thing pushing the list down the surface. `workspaceLabels.search` and `createNamed` go with it across all nine locales; `clearSearch` stays because the manager modal still owns a search field (Phase 4's copy, untouched here). `Create label` moves below a separator at the bottom and opens a page that replaces the list: a name field, the swatch row, one commit row. Picking a colour picks a colour — it no longer creates the label. A failed create keeps the draft and shows the error on the page; a successful one creates, assigns, and returns to the list with the row ticked. Both menus share one page definition. `SidebarWorkspaceMenu` and `SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call `useWorkspaceLabelMenuPages`. Menu engine, all of it forced by putting a text input on a menu page: - `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry and lands its text 4pt inside the labels above it. This one is drawn as a row's own fill, so the box and the text sit on the row's two rails at once. - `MenuPageDefinition.hoverIntent` — a page you type into is not one the pointer opens or dismisses. Without it the create flyout opened on a pointer passing over the row, and threw the draft away when the mouse drifted off it while the hands were on the keyboard. - The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With `enableDynamicSizing` the largest snap point is the content's own height, so a short page did not grow and the keyboard came up over the field. Caught on an iOS simulator, fixed, and re-shot. - `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said so; a check drawn and never announced would have been an accessibility regression. Tri-state rows (the filter page's exclude) still cannot announce themselves — menu-wide, still Phase 5's. `sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact` is kept deliberately. On a compact web viewport there is no hover, so the row's trailing actions and kebab were unreachable and the labels menu with them; this is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md. Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the create page on iOS with the keyboard up (/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced (-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten for the new model, daemon-backed failure coverage kept for both the toggle and the create path, green with the menu unit suites. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): edit a label in one operation `workspace.label.rename.request` and `workspace.label.recolor.request` become one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs are deleted outright — no COMPAT shim, because the labels feature is unreleased and neither name has ever been on a wire a user owns. The `workspaceLabels` capability flag stays; that one is how a new app detects an old daemon. Two calls could land half-applied, which left the catalog in a state nobody asked for and the UI with nothing true to say. One catalog commit now applies the name, the colour, or both. Renaming onto a name the host already has is rejected rather than merged, and the check runs inside the planner so a refused collision applies neither field. A label keeping its own key is not colliding with itself, so case-only edits pass. The manager modal follows: - The host selector is `HostFilter`, the pill History and Schedules use. `includeAllHost` is now a prop: a surface that edits one host's catalog cannot carry out "all". - Search moves to the sheet header, where it spans the header's own rails. As a body control it capped at the component's 420pt and left a hole on the right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch` goes with the box that had a clear button. - A row no longer opens an editor. It carries a pencil in its trailing slot, hover-revealed on web and always visible on native and compact, on the canonical plain-View hover pattern with the press on an inner Pressable. - The edit view is a name field, the swatch row, and one Save that issues one update. Delete keeps its confirm and its affected-workspace count, and moves to `outline` — red belongs to the dialog, not to the surface that opens it. - Copy says edit, never rename or recolor, in all nine locales. `workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client appends to every RPC failure. That is what a log wants, not what a user reads under a name field. `workspaceLabels.accessibility.color` stays deleted: the swatch's accessible name is already the colour's name, so nothing needed it back. Co-authored-by: Christoph Leiter <christoph@leiter.io> * chore(app): converge the labels phases The sweep at the end of the labels redesign: the three items earlier phases found and kept out of their own commits, one cross-phase pass, and the docs. `AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead of `padding`, which had inset its glyph 8pt from the rail the title, the search field and every trailing glyph below it sit on. This is shared chrome, so it moves the close glyph in every sheet in the app; measured on four of them across both paths — the labels manager desktop and compact, the shortcuts dialog, the rename sheet — and each one's trailing rail now agrees with its leading one. `MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a screen reader: the check announced itself after the last phase, but exclude had no state at all, so an excluded label was indistinguishable from an untouched one. `"mixed"` is ARIA's own third state, the engine still draws the check only for `true`, and the row keeps supplying its own trailing mark — nothing in the menu engine learns what a label is. CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/ `onPointerLeave`", which is the opposite of what `docs/hover.md` and `constants/platform.ts` both mandate. It now names the real rule — hover on a plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/ `onHoverOut` on a `Pressable` that contains other pressables — and links the doc that owns it rather than restating it. The cross-phase pass turned up one real defect: the replica cache never learned about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace came back unlabelled, and since the directory cursor is current on reconnect the daemon has nothing newer to send — the chips stayed gone for the whole session and the filter matched nothing. An optional field, a serializer line, and an assertion in the restore spec. Everything else measured onto its rails already. Left alone and logged: the per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing when the sidebar swaps to its empty state, which is confined to that swap and predates every phase here. `docs/architecture.md` and `docs/data-model.md` described rename and recolor; they now describe the one update commit, which fields it rewrites, and the per-host resolution a row's chips use. `docs/menus.md` documents the third item state where it already documents the other two. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): split the labels filter into include and exclude controls The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare operation to the common one: clearing an include cost two presses and routed through exclude, so the sidebar visibly re-filtered into a state nobody asked for on the way out. The row now owns include and a control on its trailing rail owns exclude. Each is one press each way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and `toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the other for that label, so including an excluded row is still one press. A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check, a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it nor changing state moves anything: measured 453-469 on every row, wide and compact. The control carries a tooltip, gated to web where hover exists. It portals into the overlay root above the flyout with no backdrop of its own, and resting on it does not read as the pointer leaving the surface, so the menu stays up. Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The row keeps Phase 5's `selected="mixed"` for exclude. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): put include and exclude on their own rails on every label row Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it reads as delete rather than exclude, and one rail with a changing occupant still means the row's trailing area keeps changing identity as you read down the column. Both controls are now on every row in fixed positions, and state is which one is lit. Nothing appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an included row, an excluded row and an untouched one alike, wide and compact. Because the ban is always there, include -> exclude is one press instead of two. The row still includes, and the check is that same action named. `Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical correction available before it stops being a glyph. The box is what the rail is measured from and it stays 16. Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s own check in that colour to mean *selected*, so an unlit check in the same ink would say the opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row. Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons carry no checked state of their own, so a label's state is announced once and its two controls read as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`, because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop. Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0` mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged; both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are. The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by a rail measurement taken in every state — relative to the row, since filtering moves the anchor the menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is not a rail. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): reveal the label filter's controls, and stop the empty state closing the menu Phase 7 put the check and the ban on every label row at all times, which made a resting list of labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row brings both controls up, the mark you have lit and the other one unlit beside it. So the column you read down is the filter, and the row you are pointing at is the one offering to change it. Nothing about the rails changed. The two boxes are laid out in every state and only their ink comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one, hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the `MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable` (docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never means pressing something invisible; and `pointerEvents` follows visibility so a hidden control cannot be clicked. On native and compact there is no pointer, so both simply stand there. Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with `hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named actions, and both tooltips gated `enabledOnDesktop={isWeb}`. --- Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed in Phase 5 to the empty-state swap, owned by nobody. `SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own container and scroll view, and both branches were handed the same header element. Different subtrees, so React unmounted the header on the swap — and the header holds the display menu's trigger, so the popover went with its anchor. The empty state is now a body: both list modes render it where their groups would go, inside the scroll they already own and below the header they already render, and the swap can no longer reach anything above it. Cleared from that same still-open page, the way back out is the swap in reverse and it holds too. `ProjectModeList` was already the right shape — its "no projects yet" body sits below the header — but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is the same bug written as a guard: keep the header when there is a filter to undo. The label filter joins that condition rather than getting a second mechanism. Both empty states move into `sidebar/empty-states.tsx` so the two list modes can render either without importing each other, which also leaves the card's geometry defined once instead of once per body. Still conditional, and left alone: a sidebar with no projects and no filter has no header at all, so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the onboarding empty state, where a section heading over "Add a project" would be a heading over nothing, and no menu is reachable in the state it lands in. The e2e gets the step that would have caught this: exclude into the empty state, assert the page you pressed on is still up, clear from it, assert the same. Verified by reverting both list files to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for the four states of the reveal. Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an included row with the pointer away, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter <christoph@leiter.io> * feat(app): make the label filter include-only Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row: tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons. Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever complained about that one. A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing. Nothing else. Which makes the alignment law trivial to satisfy, and that is the point. Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the lit/unlit token pair, `Ban`, and the two rails they held. `SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` / `exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current schema before `migrate` ever runs. Two things this leaves unused, both deleted: - `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce itself. The filter row was the only caller, so the union collapses back to `boolean` and the two `selected === true` guards that existed for `"mixed"` go back to plain truthiness. - The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing control inside a menu row. No caller does that any more, so the doc was describing a pattern the codebase no longer has. Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any other; `Match any` / `Match all`, because multi-select include still means two different things. Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the header's survival condition reads, and it answers the same question against the new shape. Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): give label-grouped rows their project icons, and label groups a tag Grouping the sidebar by label rendered every workspace row with its placeholder initial. The same workspace under status or project grouping showed its real project icon. The components were reused; the data feeding them was not. One component served both grouping modes and threaded the icon map through correctly. Fourteen lines above it, a second `useProjectIcons` call was gated on `groupMode === "status"`, written when status was the only mode that put icons on rows. Phase 2 extended the render branch to `status || label` and left the gate alone. Nothing forces a render branch and the data it renders to agree, and these two were far enough apart to drift in silence. Reuse buys you one implementation of the drawing; it buys you nothing about who decides what to fetch. So the fix is the shape, not the line. There is now one `useProjectIcons` call for every mode, and its targets come from `buildSidebarProjection` — the same function that produced the rows, from the same `projects` list. A mode can only render a row for a project the projection already knows about, so the map cannot disagree with the screen. The gate is gone rather than extended: extending it would leave the identical trap for the next grouping mode. `useProjectIcons` dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was a premature optimization that bought a bug. Pinned by `sidebar-projection.test.ts`: for each of the three modes, every project view key the projection will render — pinned rows, grouped rows, project headers and the rows under them — has an icon target. Red on the old gate: restoring `groupMode === "status" ? … : []` in its new home fails project and label grouping with `['project', 'other-project']` uncovered, and status passes, which is exactly the bug's signature. The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one more of the same shape, correct today: the projection chose its shortcut sections with `status || label`, so a fourth mode would have silently walked the project rows. Both the groups and the sections now come from one exhaustive `switch` on the mode, which makes the next mode a compile error rather than an empty list. `workspaceEntriesByKey` is already written the safe way round (`!== "project"`), and the rest — host badges, pinning support, the empty-filter state — never read the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is `SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new mode lands in the grouped list instead of the project list's `else`. Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the label's colour, muted for `Unlabelled` — the glyph the Labels menu and the workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14 with no optical nudge, and that is measured rather than assumed: in the header's leading slot both ink a 14x14 box at the same offset, and the tag lays down 76 ink pixels to the circle's 84, so reaching the corners of its box costs it no weight beside the status glyphs. QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and the same workspaces under status grouping, plus the hover chevron swap, the glyph zooms, and a before/after pair taken with the fix reverted to the bug's shape. Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): align label chip typography Co-authored-by: Christoph Leiter <christoph@leiter.io> * docs: format architecture module table Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): narrow workspace labels workflow Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): mirror bottom sheet test exports Co-authored-by: Christoph Leiter <christoph@leiter.io> * test(desktop): assert project browse by destination Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): retain empty projects without label filters Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): preserve status grouping test contract Co-authored-by: Christoph Leiter <christoph@leiter.io> * fix(app): retain filters after label load failures Co-authored-by: Christoph Leiter <christoph@leiter.io> --------- Co-authored-by: Christoph Leiter <christoph@leiter.io>
This commit is contained in:
co-authored by
Christoph Leiter
parent
5997cc1c17
commit
c779ef06c5
@@ -19,6 +19,11 @@ export interface HostFilterProps {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
/**
|
||||
* Offer "All hosts". Off for a surface that acts on exactly one host's data — the label
|
||||
* manager edits a single host's catalog, so "all" is not an answer it could carry out.
|
||||
*/
|
||||
includeAllHost?: boolean;
|
||||
triggerTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
}
|
||||
@@ -32,6 +37,7 @@ export function HostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
includeAllHost = true,
|
||||
triggerTestID,
|
||||
hostOptionTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
@@ -39,8 +45,8 @@ export function HostFilter({
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost }),
|
||||
[hosts, includeAllHost, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
@@ -62,7 +68,7 @@ export function HostFilter({
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
includeAllHost={includeAllHost}
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
|
||||
@@ -51,7 +51,8 @@ import {
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
@@ -83,9 +84,11 @@ const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() ||
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusGroups: StatusGroup[];
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
@@ -140,11 +143,13 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
|
||||
const {
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
@@ -242,9 +247,11 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -595,9 +602,11 @@ function SidebarFooter({
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -712,9 +721,11 @@ function MobileSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -742,9 +753,11 @@ function MobileSidebar({
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -913,9 +926,11 @@ function DesktopSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
|
||||
@@ -71,7 +71,11 @@ import {
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
useSidebarViewStore,
|
||||
type SidebarGroupMode,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -94,7 +98,7 @@ import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reor
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
@@ -113,12 +117,15 @@ import {
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
||||
import {
|
||||
SidebarLabelFilterEmptyState,
|
||||
SidebarProjectEmptyState,
|
||||
} from "@/components/sidebar/empty-states";
|
||||
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -129,9 +136,9 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
resolveSidebarProjectIconTargets,
|
||||
resolveSidebarProjectLocalPath,
|
||||
type SidebarProjectHostTarget,
|
||||
type SidebarProjectIconTarget,
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -233,14 +240,17 @@ function selectionForSelectedWorkspace(
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusGroups: StatusGroup[];
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
/** What `useProjectIcons` is asked for, straight from the projection. See `SidebarProjection`. */
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
groupMode: "project" | "status";
|
||||
groupMode: SidebarGroupMode;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onWorkspacePress?: () => void;
|
||||
@@ -692,6 +702,9 @@ function WorkspaceRowRightGroup({
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
workspaceLabels={workspace.labels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
@@ -1084,9 +1097,9 @@ function WorkspaceRowInner({
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -1918,9 +1931,11 @@ function areProjectBlockSelectionsEqual(
|
||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -1951,6 +1966,9 @@ export function SidebarWorkspaceList({
|
||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
|
||||
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
|
||||
const hasActiveLabelFilter = useSidebarViewStore((state) =>
|
||||
hasActiveSidebarLabelFilter(state.labelFilter),
|
||||
);
|
||||
const handlePinnedWorkspaceReorder = useCallback(
|
||||
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
@@ -1973,24 +1991,27 @@ export function SidebarWorkspaceList({
|
||||
},
|
||||
[getPinnedWorkspaceOrder, setPinnedWorkspaceOrder],
|
||||
);
|
||||
// Status mode drops the project grouping, so its rows carry their own project
|
||||
// icon. Project mode fetches the same icons inside ProjectModeList for its
|
||||
// project headers, so only the active mode requests them.
|
||||
const statusProjectIconTargets = useMemo(
|
||||
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
|
||||
[groupMode, projects],
|
||||
);
|
||||
const statusProjectIconByProjectViewKey = useProjectIcons({
|
||||
projects: statusProjectIconTargets,
|
||||
});
|
||||
// One fetch, one map, every mode — project mode paints icons on its headers and status mode
|
||||
// paints them on each row, all keyed by `projectViewKey`. The targets come from the projection
|
||||
// that produced the rows, so the question "what is on screen" is answered once.
|
||||
const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets });
|
||||
|
||||
// A filter that matches nothing swaps the list's body and nothing above it. It used to replace
|
||||
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
||||
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
||||
const labelFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0;
|
||||
|
||||
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
||||
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
||||
// one's `else`.
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
<SidebarStatusModeWrapper
|
||||
statusGroups={statusGroups}
|
||||
groupMode !== "project" ? (
|
||||
<SidebarGroupedModeList
|
||||
workspaceGroups={workspaceGroups}
|
||||
pinnedGroups={pinnedGroups}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -1998,6 +2019,7 @@ export function SidebarWorkspaceList({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2006,6 +2028,7 @@ export function SidebarWorkspaceList({
|
||||
projects={projects}
|
||||
pinnedGroups={pinnedGroups}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
@@ -2013,6 +2036,7 @@ export function SidebarWorkspaceList({
|
||||
onAddProject={onAddProject}
|
||||
listFooterComponent={listFooterComponent}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
@@ -2027,8 +2051,14 @@ export function SidebarWorkspaceList({
|
||||
return content;
|
||||
}
|
||||
|
||||
function SidebarStatusModeWrapper({
|
||||
statusGroups,
|
||||
/**
|
||||
* Every grouping mode except project: the rows are grouped by something that is not a project, so
|
||||
* each row carries its own project icon. Named for what it does rather than for the first mode
|
||||
* that needed it — `SidebarStatusModeWrapper` is what made a label-mode reader believe the data
|
||||
* above it was status-only.
|
||||
*/
|
||||
function SidebarGroupedModeList({
|
||||
workspaceGroups,
|
||||
pinnedGroups,
|
||||
workspaceEntriesByKey,
|
||||
projectIconByProjectViewKey,
|
||||
@@ -2039,10 +2069,11 @@ function SidebarStatusModeWrapper({
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
statusGroups: StatusGroup[];
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
@@ -2053,6 +2084,7 @@ function SidebarStatusModeWrapper({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
labelFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}) {
|
||||
@@ -2068,7 +2100,7 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
return (
|
||||
<SidebarStatusWorkspaceList
|
||||
groups={statusGroups}
|
||||
groups={workspaceGroups}
|
||||
pinnedWorkspaces={pinnedWorkspaces}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
@@ -2079,6 +2111,7 @@ function SidebarStatusModeWrapper({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2089,6 +2122,7 @@ function ProjectModeList({
|
||||
projects,
|
||||
pinnedGroups,
|
||||
workspaceEntriesByKey,
|
||||
projectIconByProjectViewKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2096,6 +2130,7 @@ function ProjectModeList({
|
||||
onAddProject,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
@@ -2104,7 +2139,18 @@ function ProjectModeList({
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
| "workspaceGroups"
|
||||
| "projectIconTargets"
|
||||
| "groupMode"
|
||||
| "hasProjectsBeforeLabelFilter"
|
||||
| "isRefreshing"
|
||||
| "onRefresh"
|
||||
> & {
|
||||
/** Swaps the list body for the label filter's empty state. Never the header above it. */
|
||||
labelFilterEmpty: boolean;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
pathname: string;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
@@ -2113,7 +2159,6 @@ function ProjectModeList({
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
}) {
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
@@ -2142,7 +2187,6 @@ function ProjectModeList({
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]);
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
parentGestureRef
|
||||
@@ -2157,10 +2201,6 @@ function ProjectModeList({
|
||||
[parentGestureRef],
|
||||
);
|
||||
|
||||
const projectIconByProjectViewKey = useProjectIcons({
|
||||
projects: projectIconTargets,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
||||
return () => {
|
||||
@@ -2393,6 +2433,26 @@ function ProjectModeList({
|
||||
],
|
||||
);
|
||||
|
||||
const projectBody =
|
||||
projects.length === 0 ? (
|
||||
<SidebarProjectEmptyState onAddProject={onAddProject} />
|
||||
) : (
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={unpinnedProjects}
|
||||
keyExtractor={projectViewKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{pinnedChats.length > 0 ? (
|
||||
@@ -2425,33 +2485,13 @@ function ProjectModeList({
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
{projects.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyTitle} testID="sidebar-project-empty-state">
|
||||
{t("sidebar.project.empty.title")}
|
||||
</Text>
|
||||
<Text style={styles.emptyText}>{t("sidebar.project.empty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={unpinnedProjects}
|
||||
keyExtractor={projectViewKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
)}
|
||||
{/* The header carries the display menu, which is the only way back out of a filter, so it
|
||||
stays for as long as a filter is what emptied the list. It is absent only when the
|
||||
sidebar is genuinely empty, where a section heading would sit over nothing. */}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty
|
||||
? listHeaderComponent
|
||||
: null}
|
||||
{labelFilterEmpty ? <SidebarLabelFilterEmptyState /> : projectBody}
|
||||
{listFooterComponent}
|
||||
</>
|
||||
);
|
||||
@@ -2558,28 +2598,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
emptyContainer: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
paddingTop: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
emptyTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
projectRow: {
|
||||
position: "relative",
|
||||
minHeight: 36,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Captions,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
Clock,
|
||||
@@ -15,6 +23,7 @@ import {
|
||||
Globe,
|
||||
Server,
|
||||
Settings2,
|
||||
Tag,
|
||||
Type,
|
||||
} from "lucide-react-native";
|
||||
import {
|
||||
@@ -30,22 +39,40 @@ import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
SIDEBAR_UNLABELLED_LABEL_KEY,
|
||||
type SidebarGroupMode,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { workspaceLabelKey, type WorkspaceLabelColor } from "@getpaseo/protocol/workspace-labels";
|
||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { SIDEBAR_CHECKS_DISPLAYS, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model";
|
||||
import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
const MENU_WIDTH = 232;
|
||||
|
||||
/**
|
||||
* Unlabelled's stand-in for a color dot: the same circle at the same size, hollow.
|
||||
*
|
||||
* 11 rather than the dot's 10 because lucide draws a `size` box and puts a stroked r=10-of-24
|
||||
* circle inside it, so the ring's outer edge lands on the dots' edge at 11 and 1pt short at 10.
|
||||
* The glyphs are what have to agree here, not the boxes they are centred in.
|
||||
*/
|
||||
const UNLABELLED_MARK = <ThemedCircle size={11} uniProps={mutedIconMapping} />;
|
||||
|
||||
type OptionIcon = ComponentType<{
|
||||
size: number;
|
||||
uniProps: (theme: Theme) => { color: string };
|
||||
@@ -71,6 +98,7 @@ const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = {
|
||||
host: withUnistyles(Server),
|
||||
changeRequest: withUnistyles(GitPullRequest),
|
||||
services: withUnistyles(Globe),
|
||||
labels: withUnistyles(Tag),
|
||||
};
|
||||
|
||||
// These mark how much of the row an option spends, not what CI is, so they are the shapes each
|
||||
@@ -106,6 +134,7 @@ const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
|
||||
host: "sidebar.display.show.host",
|
||||
changeRequest: "sidebar.display.show.changeRequest",
|
||||
services: "sidebar.display.show.services",
|
||||
labels: "sidebar.display.show.labels",
|
||||
};
|
||||
|
||||
const CHECKS_DISPLAY_LABEL_KEYS: Record<SidebarChecksDisplay, string> = {
|
||||
@@ -130,6 +159,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const preferences = useSidebarDisplayPreferences();
|
||||
const hosts = useHosts();
|
||||
const { labels } = useWorkspaceLabelProjection();
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const openManager = useCallback(() => setManagerOpen(true), []);
|
||||
const closeManager = useCallback(() => setManagerOpen(false), []);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -140,6 +173,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
// Nothing to filter by means no row at all. The active-filter half is not redundant: the merged
|
||||
// catalog only counts hosts that are online, so a host dropping off would otherwise take away
|
||||
// the only way back to a filter that is still hiding workspaces.
|
||||
const showLabelFilter = labels.length > 0 || hasActiveSidebarLabelFilter(preferences.labelFilter);
|
||||
|
||||
const pages = useMemo<MenuPageDefinition[]>(() => {
|
||||
const definitions: MenuPageDefinition[] = [
|
||||
@@ -199,59 +236,188 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
|
||||
});
|
||||
}
|
||||
if (showLabelFilter) {
|
||||
definitions.push({
|
||||
id: "labelFilter",
|
||||
title: t("workspaceLabels.title"),
|
||||
content: (
|
||||
<LabelFilterPage labels={labels} preferences={preferences} onManage={openManager} />
|
||||
),
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}, [t, preferences, hosts, showHostFilter]);
|
||||
}, [t, preferences, hosts, showHostFilter, showLabelFilter, labels, openManager]);
|
||||
|
||||
return (
|
||||
<MenuRoot compactMode="sheet">
|
||||
<MenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={t("sidebar.display.trigger")}
|
||||
testID="sidebar-display-preferences-menu"
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<MenuSurface
|
||||
align="end"
|
||||
width={MENU_WIDTH}
|
||||
pages={pages}
|
||||
sheetTitle={t("sidebar.display.heading")}
|
||||
testID="sidebar-display-preferences-content"
|
||||
>
|
||||
<MenuSubTrigger
|
||||
id="grouping"
|
||||
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
|
||||
testID="sidebar-display-grouping"
|
||||
<>
|
||||
<MenuRoot compactMode="sheet">
|
||||
<MenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={t("sidebar.display.trigger")}
|
||||
testID="sidebar-display-preferences-menu"
|
||||
>
|
||||
{t("sidebar.display.grouping.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger
|
||||
id="titleSource"
|
||||
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
|
||||
testID="sidebar-display-title-source"
|
||||
<ThemedSettings2 size={14} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<MenuSurface
|
||||
align="end"
|
||||
width={MENU_WIDTH}
|
||||
pages={pages}
|
||||
sheetTitle={t("sidebar.display.heading")}
|
||||
testID="sidebar-display-preferences-content"
|
||||
>
|
||||
{t("sidebar.display.titleSource.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger id="show" testID="sidebar-display-show">
|
||||
{t("sidebar.display.show.label")}
|
||||
</MenuSubTrigger>
|
||||
{showHostFilter ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{/* A filtered sidebar looks like workspaces went missing, so the branch says so
|
||||
<MenuSubTrigger
|
||||
id="grouping"
|
||||
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
|
||||
testID="sidebar-display-grouping"
|
||||
>
|
||||
{t("sidebar.display.grouping.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger
|
||||
id="titleSource"
|
||||
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
|
||||
testID="sidebar-display-title-source"
|
||||
>
|
||||
{t("sidebar.display.titleSource.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger id="show" testID="sidebar-display-show">
|
||||
{t("sidebar.display.show.label")}
|
||||
</MenuSubTrigger>
|
||||
{showHostFilter ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{/* A filtered sidebar looks like workspaces went missing, so the branch says so
|
||||
from the root rather than making you open it to find out. */}
|
||||
<MenuSubTrigger
|
||||
id="hostFilter"
|
||||
indicator={preferences.hostFilters.length > 0}
|
||||
testID="sidebar-display-host-filter"
|
||||
>
|
||||
{t("sidebar.display.hostFilter.label")}
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
</MenuSurface>
|
||||
</MenuRoot>
|
||||
<MenuSubTrigger
|
||||
id="hostFilter"
|
||||
indicator={preferences.hostFilters.length > 0}
|
||||
testID="sidebar-display-host-filter"
|
||||
>
|
||||
{t("sidebar.display.hostFilter.label")}
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
{showLabelFilter ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuSubTrigger
|
||||
id="labelFilter"
|
||||
indicator={hasActiveSidebarLabelFilter(preferences.labelFilter)}
|
||||
testID="sidebar-display-label-filter"
|
||||
>
|
||||
{t("workspaceLabels.title")}
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
</MenuSurface>
|
||||
</MenuRoot>
|
||||
<WorkspaceLabelManagerModal visible={managerOpen} onClose={closeManager} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label you could filter by, wherever it lives, one row each.
|
||||
*
|
||||
* The catalog is the merged cross-host one on purpose: a workspace row draws its label in its own
|
||||
* host's color because it would otherwise lie, but this page is the whole set of things to filter
|
||||
* on and a per-host split would only make you visit it twice.
|
||||
*
|
||||
* Clear is absent rather than disabled when it has nothing to act on.
|
||||
*/
|
||||
function LabelFilterPage({
|
||||
labels,
|
||||
preferences,
|
||||
onManage,
|
||||
}: {
|
||||
labels: ReturnType<typeof useWorkspaceLabelProjection>["labels"];
|
||||
preferences: Preferences;
|
||||
onManage: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const selected = preferences.labelFilter.labels;
|
||||
return (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<LabelFilterItem
|
||||
key={workspaceLabelKey(label.name)}
|
||||
name={label.name}
|
||||
label={label.name}
|
||||
color={label.color}
|
||||
selected={selected.includes(workspaceLabelKey(label.name))}
|
||||
onToggle={preferences.toggleLabelFilter}
|
||||
testID={`sidebar-label-filter-option-${label.name}`}
|
||||
/>
|
||||
))}
|
||||
<LabelFilterItem
|
||||
name={SIDEBAR_UNLABELLED_LABEL_KEY}
|
||||
label={t("workspaceLabels.unlabelled")}
|
||||
color={null}
|
||||
selected={selected.includes(SIDEBAR_UNLABELLED_LABEL_KEY)}
|
||||
onToggle={preferences.toggleLabelFilter}
|
||||
testID="sidebar-label-filter-option-unlabelled"
|
||||
/>
|
||||
{hasActiveSidebarLabelFilter(preferences.labelFilter) ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.clearLabelFilter}
|
||||
testID="sidebar-label-filter-clear"
|
||||
>
|
||||
{t("workspaceLabels.filter.clear")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={onManage} testID="sidebar-label-manage">
|
||||
{t("workspaceLabels.manage.open")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One label: its colour leading, its name, and the engine's own check when it is filtered on.
|
||||
*
|
||||
* The same row the host filter uses, because it is the same question — one press in, one press
|
||||
* out, several at once. Four attempts at giving exclusion a shape in this row are in the branch
|
||||
* history; each bought a new awkwardness, because two controls in one 232pt menu row is a lot of
|
||||
* machinery for a filter nobody asked to invert.
|
||||
*/
|
||||
function LabelFilterItem({
|
||||
name,
|
||||
label,
|
||||
color,
|
||||
selected,
|
||||
onToggle,
|
||||
testID,
|
||||
}: {
|
||||
/** The filter key this row acts on. Empty for Unlabelled — see `SIDEBAR_UNLABELLED_LABEL_KEY`. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** `null` is Unlabelled, the one row with no color to stand for. */
|
||||
color: WorkspaceLabelColor | null;
|
||||
selected: boolean;
|
||||
onToggle: (name: string) => void;
|
||||
testID: string;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onToggle(name), [name, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (color ? <WorkspaceLabelDot color={color} /> : UNLABELLED_MARK),
|
||||
[color],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={testID}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
type WorkspaceTitleSource,
|
||||
} from "@/hooks/use-settings";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
useSidebarViewStore,
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -26,6 +30,9 @@ export interface SidebarDisplayPreferences {
|
||||
hostFilters: readonly string[];
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
labelFilter: SidebarLabelFilter;
|
||||
toggleLabelFilter: (name: string) => void;
|
||||
clearLabelFilter: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +49,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const toggleLabelFilter = useSidebarViewStore((state) => state.toggleLabelFilter);
|
||||
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
|
||||
|
||||
const {
|
||||
settings: {
|
||||
@@ -100,6 +110,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
labelFilter,
|
||||
toggleLabelFilter,
|
||||
clearLabelFilter,
|
||||
}),
|
||||
[
|
||||
grouping,
|
||||
@@ -115,6 +128,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
labelFilter,
|
||||
toggleLabelFilter,
|
||||
clearLabelFilter,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("parseSidebarRowItems", () => {
|
||||
host: true,
|
||||
changeRequest: true,
|
||||
services: true,
|
||||
labels: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SIDEBAR_ROW_ITEMS = [
|
||||
"host",
|
||||
"changeRequest",
|
||||
"services",
|
||||
"labels",
|
||||
] as const;
|
||||
|
||||
export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number];
|
||||
@@ -31,6 +32,7 @@ export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
|
||||
host: true,
|
||||
changeRequest: true,
|
||||
services: true,
|
||||
labels: true,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
|
||||
/**
|
||||
* The two things the sidebar's workspace list says when it has no rows.
|
||||
*
|
||||
* Both are bodies, not screens: a list mode renders one where its groups would go, inside its own
|
||||
* scroll and below the section header it already owns. The header carries the display menu's
|
||||
* trigger, and an empty state that replaces the list rather than its body takes the header with
|
||||
* it — which is how filtering the last row away used to close the menu you were filtering from.
|
||||
*/
|
||||
export function SidebarLabelFilterEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="sidebar-label-filter-empty-state">
|
||||
<Text style={styles.title}>{t("workspaceLabels.filter.noMatchesTitle")}</Text>
|
||||
<Text style={styles.description}>{t("workspaceLabels.filter.noMatchesDescription")}</Text>
|
||||
<Button variant="ghost" size="sm" onPress={clearLabelFilter}>
|
||||
{t("workspaceLabels.filter.clear")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="sidebar-project-empty-state">
|
||||
<Text style={styles.title}>{t("sidebar.project.empty.title")}</Text>
|
||||
<Text style={styles.description}>{t("sidebar.project.empty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
paddingTop: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textAlign: "center",
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { SIDEBAR_UNLABELLED_LABEL_KEY } from "@/stores/sidebar-view-store";
|
||||
import { filterWorkspacesByLabels } from "./sidebar-labels";
|
||||
|
||||
function workspace(
|
||||
workspaceId: string,
|
||||
labels: string[],
|
||||
pinnedAt: string | null = null,
|
||||
): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `host:${workspaceId}`,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
projectViewKey: "project",
|
||||
projectName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${workspaceId}`,
|
||||
workspaceDirectoryLabel: workspaceId,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: workspaceId,
|
||||
title: null,
|
||||
pinnedAt,
|
||||
labels,
|
||||
currentBranch: "main",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar label filtering", () => {
|
||||
const workspaces = [
|
||||
workspace("one", ["Backend", "Urgent"], "2026-01-01"),
|
||||
workspace("two", ["Backend"]),
|
||||
workspace("three", []),
|
||||
];
|
||||
|
||||
function filtered(labels: string[]) {
|
||||
return filterWorkspacesByLabels({ workspaces, labels }).map((entry) => entry.workspaceId);
|
||||
}
|
||||
|
||||
test("includes a workspace carrying any selected label", () => {
|
||||
expect(filtered([])).toEqual(["one", "two", "three"]);
|
||||
expect(filtered(["backend"])).toEqual(["one", "two"]);
|
||||
expect(filtered(["backend", "urgent"])).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
test("models Unlabelled as a row in the same list", () => {
|
||||
expect(filtered([SIDEBAR_UNLABELLED_LABEL_KEY])).toEqual(["three"]);
|
||||
expect(filtered(["backend", SIDEBAR_UNLABELLED_LABEL_KEY])).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
|
||||
test("reads whitespace-only label names as no label rather than as the Unlabelled key", () => {
|
||||
expect(
|
||||
filterWorkspacesByLabels({
|
||||
workspaces: [workspace("blank", [" "])],
|
||||
labels: [SIDEBAR_UNLABELLED_LABEL_KEY],
|
||||
}).map((entry) => entry.workspaceId),
|
||||
).toEqual(["blank"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { SIDEBAR_UNLABELLED_LABEL_KEY, type SidebarLabelFilter } from "@/stores/sidebar-view-store";
|
||||
import type { StatusBucket, StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
export interface SidebarWorkspaceGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: SidebarWorkspaceEntry[];
|
||||
leading: { kind: "status"; bucket: StatusBucket };
|
||||
}
|
||||
|
||||
export function statusWorkspaceGroups(groups: readonly StatusGroup[]): SidebarWorkspaceGroup[] {
|
||||
return groups.map((group) => ({
|
||||
key: group.bucket,
|
||||
label: group.label,
|
||||
rows: group.rows,
|
||||
leading: { kind: "status", bucket: group.bucket },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the Labels page's selection to the sidebar.
|
||||
*
|
||||
* `Unlabelled` is a row like any other, so it is a key in the same list rather than a boolean
|
||||
* beside it; the only thing that makes it special is what the key asks of a workspace.
|
||||
* Selecting several labels includes workspaces carrying any of them.
|
||||
*/
|
||||
export function filterWorkspacesByLabels(
|
||||
input: { workspaces: readonly SidebarWorkspaceEntry[] } & SidebarLabelFilter,
|
||||
): SidebarWorkspaceEntry[] {
|
||||
const { workspaces, labels } = input;
|
||||
if (labels.length === 0) return [...workspaces];
|
||||
return workspaces.filter((workspace) => {
|
||||
// Whitespace-only names normalize away, so `size === 0` is exactly "carries no real label"
|
||||
// and the empty key can only ever mean Unlabelled.
|
||||
const keys = new Set((workspace.labels ?? []).map(workspaceLabelKey).filter(Boolean));
|
||||
const matches = (key: string) =>
|
||||
key === SIDEBAR_UNLABELLED_LABEL_KEY ? keys.size === 0 : keys.has(key);
|
||||
return labels.some(matches);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
useSidebarViewStore,
|
||||
type SidebarGroupMode,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { buildSidebarProjection } from "./sidebar-projection";
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { filterWorkspacesByLabels, type SidebarWorkspaceGroup } from "./sidebar-labels";
|
||||
import {
|
||||
hasAuthoritativeWorkspaceLabelCatalog,
|
||||
useWorkspaceLabelProjection,
|
||||
} from "@/workspace-labels";
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
toggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
@@ -24,7 +35,6 @@ interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
}
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
const EMPTY_WORKSPACE_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
@@ -35,57 +45,90 @@ export function SidebarModelProvider({
|
||||
}) {
|
||||
const list = useSidebarWorkspacesList();
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const reconcileLabelFilter = useSidebarViewStore((state) => state.reconcileLabelFilter);
|
||||
const { hosts: labelHosts } = useWorkspaceLabelProjection();
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
const isStatusMode = groupMode === "status";
|
||||
const availableLabelNames = useMemo(
|
||||
() => labelHosts.flatMap((host) => host.labels.map((label) => label.name)),
|
||||
[labelHosts],
|
||||
);
|
||||
const hasAuthoritativeLabelCatalog = hasAuthoritativeWorkspaceLabelCatalog(labelHosts);
|
||||
useEffect(() => {
|
||||
if (!hasAuthoritativeLabelCatalog) return;
|
||||
reconcileLabelFilter(availableLabelNames);
|
||||
}, [availableLabelNames, hasAuthoritativeLabelCatalog, reconcileLabelFilter]);
|
||||
const hasActiveLabelFilter = hasActiveSidebarLabelFilter(labelFilter);
|
||||
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || isStatusMode,
|
||||
active !== false || needsWorkspaceEntries,
|
||||
);
|
||||
const projectionWorkspaceEntriesByKey = isStatusMode
|
||||
? workspaceEntriesByKey
|
||||
: EMPTY_WORKSPACE_ENTRIES;
|
||||
const pinnedKeys = usePinnedSidebarKeys(list.projects);
|
||||
const projection = useMemo(
|
||||
() =>
|
||||
buildSidebarProjection({
|
||||
projects: list.projects,
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
|
||||
projectNamesByViewKey: list.projectNamesByViewKey,
|
||||
groupMode,
|
||||
pinnedCollapsed,
|
||||
collapsedProjectKeys,
|
||||
collapsedStatusGroupKeys,
|
||||
}),
|
||||
const filteredWorkspaceEntriesByKey = useMemo(() => {
|
||||
const filtered = filterWorkspacesByLabels({
|
||||
workspaces: [...workspaceEntriesByKey.values()],
|
||||
...labelFilter,
|
||||
});
|
||||
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
|
||||
}, [labelFilter, workspaceEntriesByKey]);
|
||||
const visibleWorkspaceKeys = useMemo(
|
||||
() => new Set(filteredWorkspaceEntriesByKey.keys()),
|
||||
[filteredWorkspaceEntriesByKey],
|
||||
);
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!hasActiveLabelFilter) return list.projects;
|
||||
return list.projects.flatMap((project) => {
|
||||
const workspaces = project.workspaces.filter((workspace) =>
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
|
||||
});
|
||||
}, [hasActiveLabelFilter, list.projects, visibleWorkspaceKeys]);
|
||||
const pinnedKeys = usePinnedSidebarKeys(filteredProjects);
|
||||
const projectionInput = useMemo(
|
||||
() => ({
|
||||
projects: filteredProjects,
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
workspaceEntriesByKey: filteredWorkspaceEntriesByKey,
|
||||
projectNamesByViewKey: list.projectNamesByViewKey,
|
||||
groupMode,
|
||||
pinnedCollapsed,
|
||||
collapsedProjectKeys,
|
||||
collapsedWorkspaceGroupKeys,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
collapsedStatusGroupKeys,
|
||||
collapsedWorkspaceGroupKeys,
|
||||
groupMode,
|
||||
list.projectNamesByViewKey,
|
||||
list.projects,
|
||||
filteredProjects,
|
||||
pinnedCollapsed,
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
projectionWorkspaceEntriesByKey,
|
||||
filteredWorkspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
workspaceEntriesByKey,
|
||||
projects: filteredProjects,
|
||||
hasProjectsBeforeLabelFilter: list.projects.length > 0,
|
||||
workspaceEntriesByKey: filteredWorkspaceEntriesByKey,
|
||||
groupMode,
|
||||
statusGroups: projection.statusGroups,
|
||||
workspaceGroups: projection.workspaceGroups,
|
||||
projectIconTargets: projection.projectIconTargets,
|
||||
pinnedGroups: projection.pinnedGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
@@ -95,9 +138,10 @@ export function SidebarModelProvider({
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
filteredProjects,
|
||||
projection,
|
||||
toggleProjectCollapsed,
|
||||
workspaceEntriesByKey,
|
||||
filteredWorkspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@ import type {
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildSidebarProjection } from "./sidebar-projection";
|
||||
|
||||
function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done") {
|
||||
function makeWorkspace(
|
||||
id: string,
|
||||
statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done",
|
||||
labels: string[] = [],
|
||||
projectViewKey = "project",
|
||||
) {
|
||||
const placement: SidebarWorkspacePlacement = {
|
||||
workspaceKey: `srv:${id}`,
|
||||
serverId: "srv",
|
||||
workspaceId: id,
|
||||
projectViewKey: "project",
|
||||
projectViewKey,
|
||||
projectName: "Project",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
@@ -32,21 +37,25 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
labels,
|
||||
};
|
||||
return { placement, entry };
|
||||
}
|
||||
|
||||
function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
|
||||
function makeProject(
|
||||
workspaces: SidebarWorkspacePlacement[],
|
||||
viewKey = "project",
|
||||
): SidebarProjectEntry {
|
||||
return {
|
||||
viewKey: "project",
|
||||
viewKey,
|
||||
projectName: "Project",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo",
|
||||
iconWorkingDir: `/repo/${viewKey}`,
|
||||
hosts: [
|
||||
{
|
||||
serverId: "srv",
|
||||
projectId: "project",
|
||||
iconWorkingDir: "/repo",
|
||||
projectId: viewKey,
|
||||
iconWorkingDir: `/repo/${viewKey}`,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
@@ -75,11 +84,59 @@ function projectionInput(options?: {
|
||||
groupMode: options?.groupMode ?? ("project" as const),
|
||||
pinnedCollapsed: options?.pinnedCollapsed ?? false,
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
collapsedStatusGroupKeys: new Set<string>(),
|
||||
collapsedWorkspaceGroupKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two projects, one workspace each, both labelled — so every grouping mode puts rows from more
|
||||
* than one project on screen, and a mode that asked for fewer icons than it renders would show it.
|
||||
*/
|
||||
function twoProjectInput(groupMode: "project" | "status") {
|
||||
const first = makeWorkspace("first", "running", ["Urgent"], "project");
|
||||
const second = makeWorkspace("second", "needs_input", ["Backend"], "other-project");
|
||||
return {
|
||||
...projectionInput({ groupMode }),
|
||||
projects: [makeProject([first.placement]), makeProject([second.placement], "other-project")],
|
||||
pinnedKeys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} },
|
||||
workspaceEntriesByKey: new Map([
|
||||
[first.entry.workspaceKey, first.entry],
|
||||
[second.entry.workspaceKey, second.entry],
|
||||
]),
|
||||
projectNamesByViewKey: new Map([
|
||||
["project", "Project"],
|
||||
["other-project", "Other project"],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSidebarProjection", () => {
|
||||
// The rule that outlived the bug it was written for: a project icon is fetched per project, so
|
||||
// whatever a mode groups by, the rows it produces can only reference projects already covered.
|
||||
for (const groupMode of ["project", "status"] as const) {
|
||||
it(`covers every row ${groupMode} grouping renders with a project icon target`, () => {
|
||||
const projection = buildSidebarProjection(twoProjectInput(groupMode));
|
||||
const covered = new Set(projection.projectIconTargets.map((target) => target.projectViewKey));
|
||||
|
||||
// Every leading visual the sidebar can paint from this projection: pinned rows, grouped
|
||||
// rows, project headers and the rows under them.
|
||||
const renderedProjectViewKeys = new Set<string>();
|
||||
for (const entry of projection.pinnedGroups.pinnedChats) {
|
||||
renderedProjectViewKeys.add(entry.projectViewKey);
|
||||
}
|
||||
for (const group of projection.workspaceGroups) {
|
||||
for (const entry of group.rows) renderedProjectViewKeys.add(entry.projectViewKey);
|
||||
}
|
||||
for (const project of projection.pinnedGroups.unpinnedProjects) {
|
||||
renderedProjectViewKeys.add(project.viewKey);
|
||||
for (const entry of project.workspaces) renderedProjectViewKeys.add(entry.projectViewKey);
|
||||
}
|
||||
|
||||
expect([...renderedProjectViewKeys].sort()).toEqual(["other-project", "project"]);
|
||||
expect([...renderedProjectViewKeys].filter((viewKey) => !covered.has(viewKey))).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("uses one pin-aware projection for project rows and shortcut order", () => {
|
||||
const projection = buildSidebarProjection(projectionInput());
|
||||
|
||||
@@ -97,8 +154,8 @@ describe("buildSidebarProjection", () => {
|
||||
it("keeps pinned chats above status groups and removes them from those groups", () => {
|
||||
const projection = buildSidebarProjection(projectionInput({ groupMode: "status" }));
|
||||
|
||||
expect(projection.statusGroups.map((group) => group.bucket)).toEqual(["needs_input"]);
|
||||
expect(projection.statusGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([
|
||||
expect(projection.workspaceGroups.map((group) => group.key)).toEqual(["needs_input"]);
|
||||
expect(projection.workspaceGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([
|
||||
"unpinned",
|
||||
]);
|
||||
expect(projection.shortcutModel.shortcutTargets).toEqual([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import {
|
||||
splitPinnedSidebarGroups,
|
||||
type PinnedSidebarGroups,
|
||||
@@ -9,19 +9,33 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
resolveSidebarProjectIconTargets,
|
||||
type SidebarProjectIconTarget,
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import {
|
||||
buildSidebarShortcutSections,
|
||||
type SidebarShortcutModel,
|
||||
type SidebarShortcutSection,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
import { statusWorkspaceGroups, type SidebarWorkspaceGroup } from "./sidebar-labels";
|
||||
|
||||
export interface SidebarProjection {
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
statusGroups: StatusGroup[];
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
/**
|
||||
* The project icons this projection needs fetched, keyed by `projectViewKey` — one per project,
|
||||
* whatever the mode groups by. It sits here rather than beside `useProjectIcons` in the list
|
||||
* because it is the same `projects` the rows above are projected from: a mode that renders a
|
||||
* row can only ever ask for an icon this list already covers. It used to be derived in the
|
||||
* list, under a `groupMode === "status"` gate written when status was the only mode that put
|
||||
* icons on rows.
|
||||
*/
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
export function buildSidebarProjection(input: {
|
||||
export interface SidebarProjectionInput {
|
||||
projects: SidebarProjectEntry[];
|
||||
pinnedKeys: PinnedSidebarKeys;
|
||||
pinnedWorkspaceOrder: string[];
|
||||
@@ -30,47 +44,63 @@ export function buildSidebarProjection(input: {
|
||||
groupMode: SidebarGroupMode;
|
||||
pinnedCollapsed: boolean;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
}): SidebarProjection {
|
||||
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
|
||||
const pinnedGroups = splitPinnedSidebarGroups({
|
||||
projects: input.projects,
|
||||
keys: input.pinnedKeys,
|
||||
pinnedWorkspaceOrder: input.pinnedWorkspaceOrder,
|
||||
});
|
||||
const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys);
|
||||
const statusGroups =
|
||||
input.groupMode === "status"
|
||||
? buildStatusGroups(
|
||||
Array.from(input.workspaceEntriesByKey.values()).filter(
|
||||
(workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey),
|
||||
),
|
||||
input.projectNamesByViewKey,
|
||||
)
|
||||
: [];
|
||||
const unpinnedWorkspaces = Array.from(input.workspaceEntriesByKey.values()).filter(
|
||||
(workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
// One switch decides both what the list groups by and what the keyboard shortcuts walk, so the
|
||||
// two cannot disagree and a new grouping mode is a compile error here rather than a silent
|
||||
// fall-through to the project rows.
|
||||
const workspaceGroups = buildWorkspaceGroups(input, unpinnedWorkspaces);
|
||||
|
||||
const sections: SidebarShortcutSection[] = [];
|
||||
if (!input.pinnedCollapsed) {
|
||||
sections.push({ workspaces: pinnedGroups.pinnedChats });
|
||||
}
|
||||
if (input.groupMode === "status") {
|
||||
sections.push(
|
||||
...statusGroups.map((group) => ({
|
||||
workspaces: group.rows,
|
||||
collapsed: input.collapsedStatusGroupKeys.has(group.bucket),
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
if (input.groupMode === "project") {
|
||||
sections.push(
|
||||
...pinnedGroups.unpinnedProjects.map((project) => ({
|
||||
workspaces: project.workspaces,
|
||||
collapsed: input.collapsedProjectKeys.has(project.viewKey),
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
sections.push(
|
||||
...workspaceGroups.map((group) => ({
|
||||
workspaces: group.rows,
|
||||
collapsed: input.collapsedWorkspaceGroupKeys.has(group.key),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pinnedGroups,
|
||||
statusGroups,
|
||||
workspaceGroups,
|
||||
projectIconTargets: resolveSidebarProjectIconTargets(input.projects),
|
||||
shortcutModel: buildSidebarShortcutSections({ sections }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Project mode keeps its project headers and groups nothing; status mode groups the rows. */
|
||||
function buildWorkspaceGroups(
|
||||
input: SidebarProjectionInput,
|
||||
unpinnedWorkspaces: SidebarWorkspaceEntry[],
|
||||
): SidebarWorkspaceGroup[] {
|
||||
switch (input.groupMode) {
|
||||
case "project":
|
||||
return [];
|
||||
case "status":
|
||||
return statusWorkspaceGroups(
|
||||
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ import type { GestureType } from "react-native-gesture-handler";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import type { StatusBucket } from "@/hooks/sidebar-status-view-model";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import { SidebarLabelFilterEmptyState } from "@/components/sidebar/empty-states";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
@@ -112,7 +115,7 @@ function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
|
||||
}
|
||||
|
||||
interface StatusWorkspaceListProps {
|
||||
groups: StatusGroup[];
|
||||
groups: SidebarWorkspaceGroup[];
|
||||
pinnedWorkspaces: SidebarWorkspaceEntry[];
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
@@ -123,6 +126,8 @@ interface StatusWorkspaceListProps {
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
|
||||
listHeaderComponent?: ReactNode;
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
labelFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
@@ -139,11 +144,12 @@ export function SidebarStatusWorkspaceList({
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
@@ -225,17 +231,21 @@ export function SidebarStatusWorkspaceList({
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
{labelFilterEmpty ? (
|
||||
<SidebarLabelFilterEmptyState />
|
||||
) : (
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedWorkspaceGroupKeys={collapsedWorkspaceGroupKeys}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -266,7 +276,7 @@ export function SidebarStatusWorkspaceList({
|
||||
|
||||
function StatusGroupList({
|
||||
groups,
|
||||
collapsedStatusGroupKeys,
|
||||
collapsedWorkspaceGroupKeys,
|
||||
projectIconByProjectViewKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
@@ -275,8 +285,8 @@ function StatusGroupList({
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
groups: SidebarWorkspaceGroup[];
|
||||
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
@@ -289,9 +299,9 @@ function StatusGroupList({
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<StatusGroupRows
|
||||
key={group.bucket}
|
||||
key={group.key}
|
||||
group={group}
|
||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||
collapsed={collapsedWorkspaceGroupKeys.has(group.key)}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndex={shortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -316,7 +326,7 @@ function StatusGroupRows({
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
group: SidebarWorkspaceGroup;
|
||||
collapsed: boolean;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
@@ -339,7 +349,7 @@ function StatusGroupRows({
|
||||
{!collapsed ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
testID={`sidebar-status-group-rows-${group.key}`}
|
||||
>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
@@ -362,7 +372,7 @@ function StatusGroupRows({
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
indented
|
||||
testID={`sidebar-status-show-more-${group.bucket}`}
|
||||
testID={`sidebar-status-group-show-more-${group.key}`}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -393,14 +403,20 @@ function buildStatusRowProjectPresentation({
|
||||
};
|
||||
}
|
||||
|
||||
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
|
||||
function StatusGroupHeader({
|
||||
group,
|
||||
collapsed,
|
||||
}: {
|
||||
group: SidebarWorkspaceGroup;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleStatusGroupCollapsed,
|
||||
const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleWorkspaceGroupCollapsed,
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
toggleStatusGroupCollapsed(group.bucket);
|
||||
}, [group.bucket, toggleStatusGroupCollapsed]);
|
||||
toggleWorkspaceGroupCollapsed(group.key);
|
||||
}, [group.key, toggleWorkspaceGroupCollapsed]);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const rowStyle = useCallback(
|
||||
@@ -417,16 +433,16 @@ function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed
|
||||
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
||||
<Pressable
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={`${group.label} status group`}
|
||||
accessibilityLabel={`${group.label} group`}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-status-group-${group.bucket}`}
|
||||
testID={`sidebar-status-group-${group.key}`}
|
||||
>
|
||||
<View style={styles.statusGroupRowLeft}>
|
||||
<View style={styles.statusGroupLeadingVisualSlot}>
|
||||
<StatusGroupLeadingVisual
|
||||
bucket={group.bucket}
|
||||
leading={group.leading}
|
||||
collapsed={collapsed}
|
||||
showChevron={isHovered}
|
||||
/>
|
||||
@@ -443,16 +459,16 @@ function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed
|
||||
}
|
||||
|
||||
function StatusGroupLeadingVisual({
|
||||
bucket,
|
||||
leading,
|
||||
collapsed,
|
||||
showChevron,
|
||||
}: {
|
||||
bucket: StatusGroup["bucket"];
|
||||
leading: SidebarWorkspaceGroup["leading"];
|
||||
collapsed: boolean;
|
||||
showChevron: boolean;
|
||||
}) {
|
||||
if (!showChevron) {
|
||||
return <StatusGroupIcon bucket={bucket} />;
|
||||
return <StatusGroupIcon bucket={leading.bucket} />;
|
||||
}
|
||||
if (collapsed) {
|
||||
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
@@ -460,7 +476,7 @@ function StatusGroupLeadingVisual({
|
||||
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
|
||||
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
|
||||
@@ -792,7 +808,8 @@ function StatusWorkspaceRowInnerContent({
|
||||
}: StatusWorkspaceRowInnerProps & {
|
||||
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const {
|
||||
@@ -991,6 +1008,9 @@ function StatusWorkspaceActionSlot({
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
workspaceLabels={workspace.labels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import {
|
||||
Archive,
|
||||
CircleCheck,
|
||||
Copy,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Tag,
|
||||
} from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -13,6 +22,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -28,6 +38,11 @@ import {
|
||||
workspaceServiceLabelKey,
|
||||
type WorkspaceServiceSummary,
|
||||
} from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
useWorkspaceLabelMenuPages,
|
||||
WORKSPACE_LABEL_PAGE_ID,
|
||||
type WorkspaceLabelTarget,
|
||||
} from "@/workspace-labels/picker";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -41,6 +56,7 @@ const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
const ThemedPinOff = withUnistyles(PinOff);
|
||||
const ThemedTag = withUnistyles(Tag);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
@@ -62,6 +78,9 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
|
||||
export interface SidebarWorkspaceMenuProps {
|
||||
workspaceKey: string;
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
workspaceLabels?: readonly string[];
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -107,6 +126,8 @@ function WorkspaceMenuItem({
|
||||
function SidebarWorkspaceMenuItems({
|
||||
surface,
|
||||
workspaceKey,
|
||||
serverId,
|
||||
workspaceId,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
@@ -125,6 +146,10 @@ function SidebarWorkspaceMenuItems({
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
const labelLeading = useMemo(
|
||||
() => <ThemedTag size={14} uniProps={foregroundMutedColorMapping} />,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -178,6 +203,15 @@ function SidebarWorkspaceMenuItems({
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{serverId && workspaceId ? (
|
||||
<DropdownMenuSubTrigger
|
||||
id={WORKSPACE_LABEL_PAGE_ID}
|
||||
leading={labelLeading}
|
||||
testID={`sidebar-workspace-menu-labels-${workspaceKey}`}
|
||||
>
|
||||
{t("workspaceLabels.title")}
|
||||
</DropdownMenuSubTrigger>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
surface={surface}
|
||||
path={openInFileManagerPath}
|
||||
@@ -202,6 +236,9 @@ function SidebarWorkspaceMenuItems({
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
workspaceKey,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceLabels,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
@@ -218,6 +255,12 @@ export function SidebarWorkspaceMenu({
|
||||
onOpenChange,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const workspaceTarget = useMemo<WorkspaceLabelTarget | null>(
|
||||
() =>
|
||||
serverId && workspaceId ? { serverId, workspaceId, labels: workspaceLabels ?? [] } : null,
|
||||
[serverId, workspaceId, workspaceLabels],
|
||||
);
|
||||
const pages = useWorkspaceLabelMenuPages(workspaceTarget);
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet" open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
@@ -229,10 +272,18 @@ export function SidebarWorkspaceMenu({
|
||||
>
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={260}
|
||||
pages={pages}
|
||||
sheetTitle={t("sidebar.workspace.actions.menu")}
|
||||
>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="dropdown"
|
||||
workspaceKey={workspaceKey}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceLabels={workspaceLabels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
@@ -312,6 +363,15 @@ export function SidebarWorkspaceContextMenu({
|
||||
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
|
||||
: null,
|
||||
});
|
||||
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
|
||||
() => ({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
labels: workspace.labels ?? [],
|
||||
}),
|
||||
[workspace],
|
||||
);
|
||||
const pages = useWorkspaceLabelMenuPages(workspaceTarget);
|
||||
|
||||
return (
|
||||
<ContextMenu open={contextMenuOpen} onOpenChange={onContextMenuOpenChange}>
|
||||
@@ -327,10 +387,14 @@ export function SidebarWorkspaceContextMenu({
|
||||
align="start"
|
||||
width={260}
|
||||
testID={`sidebar-workspace-context-menu-${workspaceKey}`}
|
||||
pages={pages}
|
||||
>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="context"
|
||||
workspaceKey={workspaceKey}
|
||||
serverId={workspaceTarget.serverId}
|
||||
workspaceId={workspaceTarget.workspaceId}
|
||||
workspaceLabels={workspaceTarget.labels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
@@ -122,6 +123,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
settings: { workspaceTitleSource },
|
||||
} = useAppSettings();
|
||||
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
||||
// The workspace carries label names; their colors live in its host's catalog, so the row is
|
||||
// where the two meet — the meta line is handed finished definitions.
|
||||
const labels = useWorkspaceLabelDefinitions(workspace.serverId, workspace.labels);
|
||||
const workspaceBranchTextStyle = useMemo(
|
||||
() => [
|
||||
styles.workspaceBranchText,
|
||||
@@ -165,6 +169,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
hostBadge={hostBadge ?? null}
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
@@ -264,7 +265,8 @@ function WorkspaceRowBody({
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
@@ -463,6 +465,9 @@ function WorkspaceRowTrailingActions({
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
workspaceLabels={workspace.labels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
GitPullRequestClosed,
|
||||
Globe,
|
||||
} from "lucide-react-native";
|
||||
import {
|
||||
workspaceLabelKey,
|
||||
type WorkspaceLabelDefinition,
|
||||
} from "@getpaseo/protocol/workspace-labels";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
|
||||
import { WorkspaceLabelChip, WORKSPACE_LABEL_CHIP_INSET } from "@/workspace-labels/chip";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -44,6 +49,9 @@ const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
|
||||
/** Stable identity so a row without labels doesn't re-select its items on every render. */
|
||||
const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = [];
|
||||
|
||||
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
|
||||
@@ -51,13 +59,17 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
/**
|
||||
* The subtitle under a workspace title: which host it lives on, its change request, that
|
||||
* change request's CI, and any running service. Everything the row knows about a workspace
|
||||
* that isn't its name.
|
||||
* change request's CI, any running service, and the labels someone put on it. Everything the
|
||||
* row knows about a workspace that isn't its name.
|
||||
*
|
||||
* Items are peers separated by a dot rather than ranked by chrome. The host used to be a
|
||||
* tinted pill on the title line, which made it the loudest thing in a row whose subject is
|
||||
* the title; flattening it lets the whole line read as one piece of secondary text and
|
||||
* leaves color to mean status.
|
||||
* the title; flattening it lets the line read as one piece of secondary text.
|
||||
*
|
||||
* Labels are the one exception, and a deliberate one. Everything else here reports state, and
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -65,12 +77,14 @@ export function WorkspaceMetaRow({
|
||||
hostBadge,
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -79,6 +93,7 @@ export function WorkspaceMetaRow({
|
||||
hasHostBadge: hostBadge !== null,
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels,
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
@@ -90,7 +105,7 @@ export function WorkspaceMetaRow({
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.kind}>
|
||||
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} />
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
@@ -100,9 +115,12 @@ export function WorkspaceMetaRow({
|
||||
function MetaItemNode({
|
||||
item,
|
||||
hostBadge,
|
||||
leading,
|
||||
}: {
|
||||
item: MetaRowItem;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
/** First on the line, so this item's ink sets the rail the title above it already uses. */
|
||||
leading: boolean;
|
||||
}): ReactNode {
|
||||
if (item.kind === "branch") {
|
||||
return <IdentityItem kind="branch" name={item.name} />;
|
||||
@@ -119,6 +137,9 @@ function MetaItemNode({
|
||||
if (item.kind === "checks") {
|
||||
return <ChecksItem summary={item.summary} label={item.label} />;
|
||||
}
|
||||
if (item.kind === "labels") {
|
||||
return <LabelsItem labels={item.labels} leading={leading} />;
|
||||
}
|
||||
return <ServiceItem summary={item.summary} />;
|
||||
}
|
||||
|
||||
@@ -136,6 +157,35 @@ function IdentityItem({ kind, name }: { kind: "branch" | "project"; name: string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label on the workspace, in one run. The chips sit closer to each other than the line's
|
||||
* items do to each other, so several labels still read as one item rather than as new peers,
|
||||
* and they take no separator between them — each chip's ground already ends it.
|
||||
*
|
||||
* The run shrinks and clips, the way the host badge does. A `+N` counter would be a second
|
||||
* thing to read on a line that exists to be skimmed.
|
||||
*
|
||||
* A workspace whose only meta item is its labels puts a chip where every other row puts a glyph,
|
||||
* and a chip's ground is chrome — so it hangs left by its own padding and the label's first
|
||||
* letter lands on the rail the title above it uses. Mid-line the ground stays in the flow: there
|
||||
* is a separator to its left, and pulling the tint up against that dot buys nothing.
|
||||
*/
|
||||
function LabelsItem({
|
||||
labels,
|
||||
leading,
|
||||
}: {
|
||||
labels: readonly WorkspaceLabelDefinition[];
|
||||
leading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.labels, leading && styles.labelsLeading]}>
|
||||
{labels.map((label) => (
|
||||
<WorkspaceLabelChip key={workspaceLabelKey(label.name)} label={label} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The only thing on the line that navigates, so it is the only thing that answers the
|
||||
* pointer: it brightens to full foreground and its icon becomes an external-link arrow,
|
||||
@@ -323,6 +373,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`.
|
||||
// Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so
|
||||
// a crowded line takes from the two of them in proportion rather than emptying one of them.
|
||||
// The host badge still gives up its space before either — see `flexShrink` in host-badge.tsx.
|
||||
labels: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
labelsLeading: {
|
||||
marginLeft: -WORKSPACE_LABEL_CHIP_INSET,
|
||||
},
|
||||
// The one item that gives way when the line runs out of room — see `ServiceItem`.
|
||||
serviceItem: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY } from "@/components/sidebar/display-preferences/checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items";
|
||||
@@ -15,6 +16,8 @@ const PR_HINT: PrHint = {
|
||||
|
||||
const SERVICE: WorkspaceServiceSummary = { name: "web", health: null };
|
||||
|
||||
const LABELS: WorkspaceLabelDefinition[] = [{ name: "Urgent", color: "red" }];
|
||||
|
||||
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
|
||||
return selectMetaRowItems({
|
||||
currentBranch: "feature/sidebar-badges",
|
||||
@@ -22,6 +25,7 @@ function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {
|
||||
hasHostBadge: true,
|
||||
prHint: PR_HINT,
|
||||
serviceSummary: SERVICE,
|
||||
labels: LABELS,
|
||||
visible: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
checksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
...overrides,
|
||||
@@ -40,9 +44,14 @@ describe("selectMetaRowItems", () => {
|
||||
"changeRequest",
|
||||
"checks",
|
||||
"services",
|
||||
"labels",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads identity, then the change, then its state, then what is running, then labels", () => {
|
||||
expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "services", "labels"]);
|
||||
});
|
||||
|
||||
it("omits what the workspace does not have", () => {
|
||||
expect(
|
||||
kinds(
|
||||
@@ -52,6 +61,7 @@ describe("selectMetaRowItems", () => {
|
||||
hasHostBadge: false,
|
||||
prHint: null,
|
||||
serviceSummary: null,
|
||||
labels: [],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
@@ -64,12 +74,22 @@ describe("selectMetaRowItems", () => {
|
||||
"changeRequest",
|
||||
"checks",
|
||||
"services",
|
||||
"labels",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries every label as one item, so the line keeps one separator however many there are", () => {
|
||||
const labels: WorkspaceLabelDefinition[] = [
|
||||
{ name: "Urgent", color: "red" },
|
||||
{ name: "Backend", color: "sky" },
|
||||
];
|
||||
expect(select({ labels }).at(-1)).toEqual({ kind: "labels", labels });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["changeRequest", ["host", "checks", "services"]],
|
||||
["services", ["host", "changeRequest", "checks"]],
|
||||
["changeRequest", ["host", "checks", "services", "labels"]],
|
||||
["services", ["host", "changeRequest", "checks", "labels"]],
|
||||
["labels", ["host", "changeRequest", "checks", "services"]],
|
||||
] as const)("drops %s and only %s when it is switched off", (item, expected) => {
|
||||
expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual(
|
||||
expected,
|
||||
@@ -77,14 +97,19 @@ describe("selectMetaRowItems", () => {
|
||||
});
|
||||
|
||||
it("drops checks and only checks when they are hidden", () => {
|
||||
expect(kinds(select({ checksDisplay: "none" }))).toEqual(["host", "changeRequest", "services"]);
|
||||
expect(kinds(select({ checksDisplay: "none" }))).toEqual([
|
||||
"host",
|
||||
"changeRequest",
|
||||
"services",
|
||||
"labels",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps checks when the change request is hidden", () => {
|
||||
// Each control answers for itself. A checks setting that drew nothing because a different
|
||||
// switch was off would be lying about its own state.
|
||||
const items = select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false } });
|
||||
expect(kinds(items)).toEqual(["host", "checks", "services"]);
|
||||
expect(kinds(items)).toEqual(["host", "checks", "services", "labels"]);
|
||||
});
|
||||
|
||||
it("draws nothing for checks when both are off", () => {
|
||||
@@ -92,7 +117,7 @@ describe("selectMetaRowItems", () => {
|
||||
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false },
|
||||
checksDisplay: "none",
|
||||
});
|
||||
expect(kinds(items)).toEqual(["host", "services"]);
|
||||
expect(kinds(items)).toEqual(["host", "services", "labels"]);
|
||||
});
|
||||
|
||||
it("carries the resolved check summary rather than the raw hint", () => {
|
||||
@@ -115,6 +140,6 @@ describe("selectMetaRowItems", () => {
|
||||
|
||||
it("keeps a change request whose forge reports no checks", () => {
|
||||
const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } });
|
||||
expect(kinds(items)).toEqual(["host", "changeRequest", "services"]);
|
||||
expect(kinds(items)).toEqual(["host", "changeRequest", "services", "labels"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import type { SidebarChecksDisplay } from "@/components/sidebar/display-preferences/checks-display";
|
||||
import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items";
|
||||
@@ -6,8 +7,13 @@ import type { WorkspaceServiceSummary } from "./service-summary";
|
||||
|
||||
/**
|
||||
* What ends up on the line under a workspace title, in the order it is read: where the
|
||||
* workspace lives, what change it belongs to, whether that change is passing, and what it is
|
||||
* running. Identity first, then the work, then the work's state.
|
||||
* workspace lives, what change it belongs to, whether that change is passing, what it is
|
||||
* running, and what someone filed it under. Identity first, then the work, then the work's
|
||||
* state, then the labels a person put on it.
|
||||
*
|
||||
* Labels are one item rather than one per label: they are drawn as a run of chips with a single
|
||||
* separator in front of them, so the line reads as four peers however many labels a workspace
|
||||
* carries.
|
||||
*/
|
||||
export type MetaRowItem =
|
||||
| { kind: "branch"; name: string }
|
||||
@@ -15,7 +21,8 @@ export type MetaRowItem =
|
||||
| { kind: "host" }
|
||||
| { kind: "changeRequest"; hint: PrHint }
|
||||
| { kind: "checks"; summary: CheckSummary; label: boolean }
|
||||
| { kind: "services"; summary: WorkspaceServiceSummary };
|
||||
| { kind: "services"; summary: WorkspaceServiceSummary }
|
||||
| { kind: "labels"; labels: readonly WorkspaceLabelDefinition[] };
|
||||
|
||||
/**
|
||||
* Which peers a row should draw, given what it knows and what the user left switched on.
|
||||
@@ -33,6 +40,7 @@ export function selectMetaRowItems(input: {
|
||||
hasHostBadge: boolean;
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels: readonly WorkspaceLabelDefinition[];
|
||||
visible: SidebarRowItems;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
}): MetaRowItem[] {
|
||||
@@ -42,6 +50,7 @@ export function selectMetaRowItems(input: {
|
||||
hasHostBadge,
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels,
|
||||
visible,
|
||||
checksDisplay,
|
||||
} = input;
|
||||
@@ -75,5 +84,9 @@ export function selectMetaRowItems(input: {
|
||||
items.push({ kind: "services", summary: serviceSummary });
|
||||
}
|
||||
|
||||
if (labels.length > 0 && visible.labels) {
|
||||
items.push({ kind: "labels", labels });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export {
|
||||
MenuItem,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuTextField,
|
||||
menuRowContentInset,
|
||||
type ActionStatus,
|
||||
type MenuItemProps,
|
||||
} from "./menu-item";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -126,6 +128,71 @@ export function MenuHint({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row's label starts, measured from the surface's edge: the fill's inset, its border and
|
||||
* its padding. Content a page renders itself — a swatch row, a caption — sits on this rail or it
|
||||
* reads as a line that slipped out of the list.
|
||||
*/
|
||||
export function menuRowContentInset(theme: Theme): number {
|
||||
return theme.spacing[1] + theme.borderWidth[1] + theme.spacing[2];
|
||||
}
|
||||
|
||||
/**
|
||||
* A text field on a menu page, drawn as the row it stands in for: same box as a row's fill, same
|
||||
* left rail for the text inside it. The form kit's `FormTextInput` carries a screen's geometry
|
||||
* (12pt padding, 32pt tall) and lands its text 4pt inside the labels above it, which is exactly
|
||||
* the misalignment a menu makes obvious.
|
||||
*
|
||||
* `AdaptiveTextInput` underneath, so a compact sheet gets `BottomSheetTextInput` and the keyboard
|
||||
* moves the sheet rather than covering it. That also means the compact presentation has to be a
|
||||
* sheet: `BottomSheetTextInput` outside a sheet has no context to read.
|
||||
*/
|
||||
export function MenuTextField({
|
||||
initialValue,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
accessibilityLabel,
|
||||
autoFocus = false,
|
||||
editable = true,
|
||||
onSubmitEditing,
|
||||
testID,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder: string;
|
||||
/** Falls back to `placeholder`, which already names the field. */
|
||||
accessibilityLabel?: string;
|
||||
autoFocus?: boolean;
|
||||
editable?: boolean;
|
||||
onSubmitEditing?: () => void;
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const handleFocus = useCallback(() => setFocused(true), []);
|
||||
const handleBlur = useCallback(() => setFocused(false), []);
|
||||
const fieldStyle = useMemo(() => [styles.field, focused ? styles.fieldFocused : null], [focused]);
|
||||
return (
|
||||
<View style={fieldStyle}>
|
||||
<AdaptiveTextInput
|
||||
initialValue={initialValue}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
placeholder={placeholder}
|
||||
accessibilityLabel={accessibilityLabel ?? placeholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus={autoFocus}
|
||||
editable={editable}
|
||||
returnKeyType="done"
|
||||
style={styles.fieldInput}
|
||||
testID={testID}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLeadingContent(input: {
|
||||
isPending: boolean | undefined;
|
||||
isSuccess: boolean;
|
||||
@@ -228,6 +295,14 @@ export function MenuItem({
|
||||
selectItem(onSelect, closeOnSelect);
|
||||
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
|
||||
|
||||
// A row that draws a check has to say so as well: a multi-select page is a list of things that
|
||||
// are on or off, and the check is the only thing telling a sighted user which. Rows that answer
|
||||
// no such question stay plain buttons.
|
||||
const accessibilityState = useMemo(
|
||||
() => (selected === undefined ? undefined : { checked: selected }),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const itemPressableStyle = useCallback(
|
||||
({
|
||||
pressed,
|
||||
@@ -263,6 +338,8 @@ export function MenuItem({
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="menuitem"
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={selected}
|
||||
tabIndex={-1}
|
||||
dataSet={itemDataSet}
|
||||
disabled={isDisabled}
|
||||
@@ -375,6 +452,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
},
|
||||
// The same box as `item`, filled the way a hovered row is filled, so the field sits in the
|
||||
// column of rows rather than beside it. Every number here is `item`'s: change one, change both.
|
||||
field: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: MENU_ITEM_HEIGHT,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
fieldFocused: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
fieldInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: MENU_ITEM_LINE_HEIGHT,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
itemHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -69,6 +69,15 @@ export interface MenuPageDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
/**
|
||||
* Whether the pointer opens and closes this page on its own. Default true.
|
||||
*
|
||||
* A page that takes typed input sets this false: hover intent would open it on a pointer that
|
||||
* was only passing through, and then dismiss it — draft and all — the moment the hands moved
|
||||
* to the keyboard and the mouse drifted off the flyout. While such a page is open, the whole
|
||||
* surface stops closing on hover, since its parent leads back to the same dismissal.
|
||||
*/
|
||||
hoverIntent?: boolean;
|
||||
}
|
||||
|
||||
interface MenuSurfaceContextValue {
|
||||
@@ -210,8 +219,25 @@ function MenuPopoverSurface({
|
||||
[menu.path, pages],
|
||||
);
|
||||
|
||||
// `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on
|
||||
// its trigger, and while it is open nothing on this surface closes on a pointer leaving it.
|
||||
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
|
||||
const locked = openPages.some(({ page }) => page.hoverIntent === false);
|
||||
return {
|
||||
...surfaceValue,
|
||||
hoverOpen: (sub) => {
|
||||
if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return;
|
||||
surfaceValue.hoverOpen(sub);
|
||||
},
|
||||
hoverClose: (depth) => {
|
||||
if (locked) return;
|
||||
surfaceValue.hoverClose(depth);
|
||||
},
|
||||
};
|
||||
}, [openPages, pages, surfaceValue]);
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<MenuSurfaceContext.Provider value={hoverValue}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose} restoreFocusRef={menu.triggerRef}>
|
||||
<>
|
||||
<AnchoredSurface
|
||||
@@ -375,7 +401,12 @@ function MenuSheetSurface({
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
// `interactive` rather than `extend`, which is what every other sheet in the app uses.
|
||||
// `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that
|
||||
// point is the content's own height — so a short page does not grow, the keyboard comes up
|
||||
// over it, and the field you are typing into is behind the keys. `interactive` moves the
|
||||
// sheet up instead, which is the only thing a content-sized sheet can usefully do.
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
|
||||
Reference in New Issue
Block a user