fix(app): retire the docked explorer sidebar
Upgrading to 0.5.0-beta.1 left the old docked explorer sidebar showing next to the new explorer pane, with no way to dismiss it. The sidebar rendered off the persisted `desktop.fileExplorerOpen` flag. #3287 rerouted the explorer toggle to the workspace-layout explorer pane, so nothing on a wide layout ever wrote that flag false again, and no migration cleared it. Assistant directory links and review attachments could still set it. On Windows and Linux the sidebar's close button was suppressed because the OS window controls own the top-right corner, so there was no affordance at all. The explorer is now one surface picked from two facts — is the layout compact, and can the platform render split panes: compact -> slide-over overlay (panel store) non-compact + splits -> dedicated right pane (layout store) non-compact, no splits -> ordinary tabs in the focused pane `workspace-tabs/explorer-surface.ts` owns that choice and every entry point routes through it, so the toggle, Ctrl+E, Ctrl+., directory links, review attachments and side file opens can no longer disagree about where the explorer lives. Pane splits stay gated on `isWeb`: they depend on dnd-kit and DOM-backed accessibility helpers. Native tablets are non-compact without splits, so they get explorer tabs rather than a second pane — deliberately trading side-by-side agent and Changes for a surface that only renders what the single-column tab row can actually reach. Ctrl+. is handled by the workspace screen rather than the app shell. The pathname alone cannot identify the active workspace: desktop cold-starts at "/" and restores the remembered workspace from route params, so a key derived from the pathname is null exactly when a workspace is mounted, and the explorer reads as permanently closed. `desktop.fileExplorerOpen` and `explorerWidth` are dropped by a migration to panel-state v15 but stay declared in the persisted schema. That schema is a strict object and the validated storage deletes the whole entry when parsing fails, so removing the keys outright would wipe every upgrading user's sidebar width, expanded paths and diff-collapse state.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
resolveExplorerPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
/**
|
||||
* The explorer has three surfaces, picked from two facts — is the layout compact,
|
||||
* and can this platform render split panes:
|
||||
*
|
||||
* compact → slide-over overlay, owned by the panel store
|
||||
* non-compact + splits → dedicated right pane, owned by the layout store
|
||||
* non-compact, no splits → ordinary tabs in the focused pane (native tablets)
|
||||
*
|
||||
* Every entry point goes through this module. Nothing else branches on the surface —
|
||||
* the third case is why: a native tablet renders one pane at a time and the tab row
|
||||
* only lists the focused pane's tabs, so anything routed into a separate explorer
|
||||
* pane there would silently disappear.
|
||||
*/
|
||||
|
||||
/** Tab kinds that are explorer views rather than user content. */
|
||||
const EXPLORER_TAB_KINDS = ["working_diff", "files", "pull_request"] as const;
|
||||
type ExplorerTabKind = (typeof EXPLORER_TAB_KINDS)[number];
|
||||
|
||||
const EXPLORER_VIEW_TARGETS: Record<ExplorerTab, WorkspaceTabTarget> = {
|
||||
changes: { kind: "working_diff" },
|
||||
files: { kind: "files" },
|
||||
pr: { kind: "pull_request" },
|
||||
};
|
||||
|
||||
export interface ExplorerSurfaceQuery {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
}
|
||||
|
||||
export interface ExplorerSurfaceInput extends ExplorerSurfaceQuery {
|
||||
checkout: ExplorerCheckoutContext | null;
|
||||
}
|
||||
|
||||
type LayoutState = ReturnType<typeof useWorkspaceLayoutStore.getState>;
|
||||
|
||||
function isExplorerTabKind(kind: string): kind is ExplorerTabKind {
|
||||
return (EXPLORER_TAB_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
function canUseExplorerPane(isCompact: boolean): boolean {
|
||||
return !isCompact && supportsDesktopPaneSplits();
|
||||
}
|
||||
|
||||
// ─── Explorer pane (non-compact + splits) ──────────────────────────────────
|
||||
|
||||
function findExplorerPane(state: LayoutState, workspaceKey: string) {
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
const paneId = resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]);
|
||||
return paneId ? findPaneById(layout.root, paneId) : null;
|
||||
}
|
||||
|
||||
function selectIsExplorerPaneOpen(state: LayoutState, workspaceKey: string | null): boolean {
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
const pane = findExplorerPane(state, workspaceKey);
|
||||
return Boolean(pane && pane.hidden !== true);
|
||||
}
|
||||
|
||||
// ─── Explorer tabs (non-compact, no splits) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* The explorer tab the focused pane is currently showing, if any. On a tablet
|
||||
* "the explorer is open" means exactly that — there is no pane to hide.
|
||||
*/
|
||||
function findActiveExplorerTabId(state: LayoutState, workspaceKey: string): string | null {
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
const focusedTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === focusedTabId);
|
||||
return tab && isExplorerTabKind(tab.target.kind) ? tab.tabId : null;
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a tab alongside the current one. With splits it lands in the explorer
|
||||
* pane; without them it opens in the focused pane, because that is the only
|
||||
* pane the user can see.
|
||||
*/
|
||||
export function openWorkspaceTabBeside(input: {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
target: WorkspaceTabTarget;
|
||||
parentTabId?: string | null;
|
||||
}): string | null {
|
||||
if (!input.workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const parentTabId = input.parentTabId?.trim() || null;
|
||||
|
||||
if (canUseExplorerPane(input.isCompact)) {
|
||||
return store.openTabInExplorerPaneFocused(input.workspaceKey, {
|
||||
target: input.target,
|
||||
parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
return parentTabId
|
||||
? store.openChildTabInFocusedPane(input.workspaceKey, input.target, parentTabId)
|
||||
: store.openTabInFocusedPane(input.workspaceKey, input.target);
|
||||
}
|
||||
|
||||
/** {@link openWorkspaceTabBeside} without moving focus. */
|
||||
export function openWorkspaceTabBesideInBackground(input: {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
target: WorkspaceTabTarget;
|
||||
}): string | null {
|
||||
if (!input.workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
return canUseExplorerPane(input.isCompact)
|
||||
? store.openTabInExplorerPaneBackground(input.workspaceKey, input.target)
|
||||
: store.openTabInBackground(input.workspaceKey, input.target);
|
||||
}
|
||||
|
||||
/** Reveal the explorer showing `view`. */
|
||||
export function openExplorerSurface(input: ExplorerSurfaceInput & { view: ExplorerTab }): void {
|
||||
if (input.isCompact) {
|
||||
if (!input.checkout) {
|
||||
return;
|
||||
}
|
||||
const panel = usePanelStore.getState();
|
||||
panel.setExplorerTabForCheckout({ ...input.checkout, tab: input.view });
|
||||
panel.openCompactFileExplorer(input.checkout);
|
||||
return;
|
||||
}
|
||||
|
||||
openWorkspaceTabBeside({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: EXPLORER_VIEW_TARGETS[input.view],
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the explorer if it is hidden, hide it if it is showing. */
|
||||
export function toggleExplorerSurface(input: ExplorerSurfaceInput): void {
|
||||
if (input.isCompact) {
|
||||
if (input.checkout) {
|
||||
usePanelStore.getState().toggleCompactFileExplorer(input.checkout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.workspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceKey = input.workspaceKey;
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
if (!canUseExplorerPane(input.isCompact)) {
|
||||
const activeExplorerTabId = findActiveExplorerTabId(store, workspaceKey);
|
||||
if (activeExplorerTabId) {
|
||||
store.closeTab(workspaceKey, activeExplorerTabId);
|
||||
return;
|
||||
}
|
||||
store.openTabInFocusedPane(workspaceKey, EXPLORER_VIEW_TARGETS.changes);
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = store.layoutByWorkspace[workspaceKey];
|
||||
const parentTabId = layout
|
||||
? (findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null)
|
||||
: null;
|
||||
const explorerPane = findExplorerPane(store, workspaceKey);
|
||||
|
||||
if (explorerPane) {
|
||||
if (explorerPane.hidden !== true) {
|
||||
store.hidePane(workspaceKey, explorerPane.id);
|
||||
return;
|
||||
}
|
||||
store.showPane(workspaceKey, explorerPane.id);
|
||||
// The companion pane can already hold an unrelated tab (the setup progress
|
||||
// tab auto-opens here) before the user ever toggles it, so "empty" is the
|
||||
// wrong signal for "needs a Changes tab seeded". Check for it specifically.
|
||||
const layoutTabs = layout ? collectAllTabs(layout.root) : [];
|
||||
const hasChangesTab = explorerPane.tabIds.some(
|
||||
(tabId) => layoutTabs.find((tab) => tab.tabId === tabId)?.target.kind === "working_diff",
|
||||
);
|
||||
if (hasChangesTab) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
store.openTabInExplorerPaneFocused(workspaceKey, {
|
||||
target: EXPLORER_VIEW_TARGETS.changes,
|
||||
parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reactive "is the explorer showing" for the surface this layout uses. */
|
||||
export function useIsExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean {
|
||||
const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const paneOpen = useWorkspaceLayoutStore((state) =>
|
||||
input.isCompact ? false : isExplorerOpenForLayout(state, input.workspaceKey),
|
||||
);
|
||||
return input.isCompact ? compactOpen : paneOpen;
|
||||
}
|
||||
|
||||
/** Imperative counterpart of {@link useIsExplorerSurfaceOpen}. */
|
||||
export function isExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean {
|
||||
if (input.isCompact) {
|
||||
return selectIsCompactFileExplorerOpen(usePanelStore.getState());
|
||||
}
|
||||
return isExplorerOpenForLayout(useWorkspaceLayoutStore.getState(), input.workspaceKey);
|
||||
}
|
||||
|
||||
function isExplorerOpenForLayout(state: LayoutState, workspaceKey: string | null): boolean {
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
return supportsDesktopPaneSplits()
|
||||
? selectIsExplorerPaneOpen(state, workspaceKey)
|
||||
: findActiveExplorerTabId(state, workspaceKey) !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user