fix(desktop): a layout says what is on screen
Applying a preset opens every pane it places and closes the ones it lists as `resting`, through the panes' own stores so ⌃`/⌘J/⌘G/⌘B stay truthful. The per-pane `revealOnPreset` flag is gone; a saved deck remembers which of its panes were closed. Basic was `sessions | workspace`, and picking it produced Focus — the apply adopts every omitted pane back in as workspace tabs, so Basic → Focus → Basic converged on one shape (in Advanced too). Basic is now sessions + chat with the terminal resting as a rail under the chat and review/files resting in a right column; Focus keeps files/review as tabs behind the chat with the same rail (a terminal tab covered the conversation, and a reveal on apply fronted it). Simple's two presets are Basic and its mirror. The mirror exposed a positional bug on main: `$fileBrowserOpen` collapsed "the right edge" whatever sat there, so a resting file tree folded the mirrored sidebar away — flip Default with ⌘\ and ⌘J hides the sessions column. An edge now belongs to the pane on it (`sidebarSide()` / `fileBrowserSide()` follow `$panesFlipped`; the tree-side bindings pick their owner the same way). Sessions gains an opener to mirror its closer. In Simple a closed terminal hides, rail and all (`bindToolPaneCollapse(…, $rail)`); ⌃` is the door for the session.
This commit is contained in:
@@ -44,11 +44,23 @@ import { registry } from '@/contrib/registry'
|
|||||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||||
import { LocalizedTabTitle, translateNow } from '@/i18n'
|
import { LocalizedTabTitle, translateNow } from '@/i18n'
|
||||||
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||||
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Users, Zap } from '@/lib/icons'
|
import {
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
PanelBottom,
|
||||||
|
PanelTop,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Terminal,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
|
Zap
|
||||||
|
} from '@/lib/icons'
|
||||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||||
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
|
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
|
||||||
import { setYoloEnabled } from '@/lib/yolo-session'
|
import { setYoloEnabled } from '@/lib/yolo-session'
|
||||||
|
import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode'
|
||||||
import {
|
import {
|
||||||
$fileBrowserOpen,
|
$fileBrowserOpen,
|
||||||
$panesFlipped,
|
$panesFlipped,
|
||||||
@@ -56,11 +68,14 @@ import {
|
|||||||
FILE_BROWSER_DEFAULT_WIDTH,
|
FILE_BROWSER_DEFAULT_WIDTH,
|
||||||
FILE_BROWSER_MAX_WIDTH,
|
FILE_BROWSER_MAX_WIDTH,
|
||||||
FILE_BROWSER_MIN_WIDTH,
|
FILE_BROWSER_MIN_WIDTH,
|
||||||
|
fileBrowserSide,
|
||||||
setFileBrowserOpen,
|
setFileBrowserOpen,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
SIDEBAR_DEFAULT_WIDTH,
|
SIDEBAR_DEFAULT_WIDTH,
|
||||||
SIDEBAR_MAX_WIDTH
|
SIDEBAR_MAX_WIDTH,
|
||||||
|
sidebarSide
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
|
import { $profiles } from '@/store/profile'
|
||||||
import { $profileRailVisible } from '@/store/profile-rail-prefs'
|
import { $profileRailVisible } from '@/store/profile-rail-prefs'
|
||||||
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
||||||
import {
|
import {
|
||||||
@@ -203,11 +218,9 @@ registry.registerMany([
|
|||||||
area: 'panes',
|
area: 'panes',
|
||||||
// Register-time sample; the tab renders `tabTitle` (see sessions).
|
// Register-time sample; the tab renders `tabTitle` (see sessions).
|
||||||
title: translateNow('sidebar.terminal'),
|
title: translateNow('sidebar.terminal'),
|
||||||
// revealOnPreset: choosing a layout that places the terminal (e.g.
|
// height sizes the fixed track (a single-pane zone declaring a height is a
|
||||||
// "Terminal deck") turns takeover on so the zone actually shows, instead of
|
// fixed track — the preset weight is moot): a short deck, not a third of
|
||||||
// staying collapsed behind the ⌃` toggle. height sizes the fixed track (a
|
// the window.
|
||||||
// single-pane zone declaring a height is a fixed track — the preset weight
|
|
||||||
// is moot): a short deck, not a third of the window.
|
|
||||||
//
|
//
|
||||||
// NO minHeight: a tool panel drags all the way down to its collapsed
|
// NO minHeight: a tool panel drags all the way down to its collapsed
|
||||||
// header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to
|
// header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to
|
||||||
@@ -216,7 +229,6 @@ registry.registerMany([
|
|||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
height: '20vh',
|
height: '20vh',
|
||||||
maxHeight: '80vh',
|
maxHeight: '80vh',
|
||||||
revealOnPreset: true,
|
|
||||||
lifecycleKeepAlive: true,
|
lifecycleKeepAlive: true,
|
||||||
tabTitle: () => <LocalizedTabTitle select={t => t.sidebar.terminal} />,
|
tabTitle: () => <LocalizedTabTitle select={t => t.sidebar.terminal} />,
|
||||||
tabTitleText: () => translateNow('sidebar.terminal')
|
tabTitleText: () => translateNow('sidebar.terminal')
|
||||||
@@ -373,6 +385,17 @@ registry.registerMany([
|
|||||||
get: () => $profileRailVisible.get(),
|
get: () => $profileRailVisible.get(),
|
||||||
set: enabled => $profileRailVisible.set(enabled)
|
set: enabled => $profileRailVisible.set(enabled)
|
||||||
}),
|
}),
|
||||||
|
// Simple hides most of the chrome that would offer the way back, so ⌘K is a
|
||||||
|
// guaranteed door (alongside the layout editor and Settings → Appearance).
|
||||||
|
paletteToggle({
|
||||||
|
id: 'view.simpleMode',
|
||||||
|
label: 'Simple mode',
|
||||||
|
action: 'view.toggleSimpleMode',
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
keywords: ['simple', 'advanced', 'mode', 'interface', 'chrome', 'minimal', 'focus', 'distraction'],
|
||||||
|
get: () => $interfaceMode.get() === 'simple',
|
||||||
|
set: toggleSimpleMode
|
||||||
|
}),
|
||||||
paletteToggle({
|
paletteToggle({
|
||||||
id: 'view.toggleTabStrip',
|
id: 'view.toggleTabStrip',
|
||||||
label: 'Toggle tabs',
|
label: 'Toggle tabs',
|
||||||
@@ -564,11 +587,22 @@ $panesFlipped.listen(flipped => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the
|
// Side toggles (titlebar buttons, ⌘B / ⌘J) collapse a whole edge of the main
|
||||||
// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on
|
// zone — everything on it hides together, whatever has been rearranged there.
|
||||||
// that side hides together, whatever panes have been rearranged there.
|
// Which store owns which edge follows the panes: mirrored, the sidebar sits
|
||||||
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
|
// on the right and $sidebarOpen goes with it (see sidebarSide in store/layout).
|
||||||
bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
|
const $leftEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) =>
|
||||||
|
flipped ? files : sidebar
|
||||||
|
)
|
||||||
|
|
||||||
|
const $rightEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) =>
|
||||||
|
flipped ? sidebar : files
|
||||||
|
)
|
||||||
|
|
||||||
|
bindTreeSideVisibility('left', $leftEdgeOpen, open => ($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open))
|
||||||
|
bindTreeSideVisibility('right', $rightEdgeOpen, open =>
|
||||||
|
($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open)
|
||||||
|
)
|
||||||
|
|
||||||
// Workspace-scoped surfaces: the file tree and git diff only mean something
|
// Workspace-scoped surfaces: the file tree and git diff only mean something
|
||||||
// inside a project. A detached chat (no cwd) hides them — their zones
|
// inside a project. A detached chat (no cwd) hides them — their zones
|
||||||
@@ -601,13 +635,19 @@ bindPaneVisibility(
|
|||||||
() => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get())
|
() => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get())
|
||||||
)
|
)
|
||||||
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
|
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
|
||||||
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
|
// hides; PTYs stay alive while collapsed (see PersistentTerminal). Simple has
|
||||||
|
// no terminal: where chrome is off a closed one hides, rail and all, and ⌃`
|
||||||
|
// is the door for the session.
|
||||||
bindToolPaneCollapse(
|
bindToolPaneCollapse(
|
||||||
'terminal',
|
'terminal',
|
||||||
$terminalTakeover,
|
$terminalTakeover,
|
||||||
() => setTerminalTakeover(false),
|
() => setTerminalTakeover(false),
|
||||||
() => setTerminalTakeover(true)
|
() => setTerminalTakeover(true),
|
||||||
|
$showsAdvancedChrome
|
||||||
)
|
)
|
||||||
|
// Policies may consult the install: the profile rail stays in Simple when a
|
||||||
|
// second profile makes it the only remaining way to switch.
|
||||||
|
$profiles.subscribe(profiles => setModeContext({ profileCount: profiles.length }))
|
||||||
// ⌘K door onto the same pane the keybind and statusbar pill flip — was a
|
// ⌘K door onto the same pane the keybind and statusbar pill flip — was a
|
||||||
// one-way "open" row under Go to, so it never showed on/off and couldn't hide.
|
// one-way "open" row under Go to, so it never showed on/off and couldn't hide.
|
||||||
// Reads the TREE like every other pane toggle: `$terminalTakeover` stays true
|
// Reads the TREE like every other pane toggle: `$terminalTakeover` stays true
|
||||||
@@ -774,12 +814,18 @@ registry.register(
|
|||||||
// flips back) — but only while the pane actually lives in that root side
|
// flips back) — but only while the pane actually lives in that root side
|
||||||
// column. Dragged next to main, a side collapse can't hide it (the collapse
|
// column. Dragged next to main, a side collapse can't hide it (the collapse
|
||||||
// skips main-bearing children), so Close falls back to dismissal there —
|
// skips main-bearing children), so Close falls back to dismissal there —
|
||||||
// otherwise ⌘W/Close silently no-op.
|
// otherwise ⌘W/Close silently no-op. The sessions opener is the mirror: a
|
||||||
|
// preset that places the sidebar shows it, ⌘B truthful.
|
||||||
registerPaneCloser('sessions', () =>
|
registerPaneCloser('sessions', () =>
|
||||||
paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions')
|
paneRootSide('sessions') === sidebarSide() ? setSidebarOpen(false) : dismissTreePane('sessions')
|
||||||
)
|
)
|
||||||
|
registerPaneOpener('sessions', () => {
|
||||||
|
if (paneRootSide('sessions') === sidebarSide()) {
|
||||||
|
setSidebarOpen(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
registerPaneCloser('files', () =>
|
registerPaneCloser('files', () =>
|
||||||
paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files')
|
paneRootSide('files') === fileBrowserSide() ? setFileBrowserOpen(false) : dismissTreePane('files')
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { group, split } from '@/components/pane-shell/tree/model'
|
import { group, mirrorTreeHorizontal, split } from '@/components/pane-shell/tree/model'
|
||||||
import { registry } from '@/contrib/registry'
|
import { registerBundledPresets } from '@/components/pane-shell/tree/presets'
|
||||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Layout presets — CHAT (main) always dominates.
|
// Layout presets — CHAT (main) always dominates.
|
||||||
@@ -39,11 +38,32 @@ export const DEFAULT_TREE = split(
|
|||||||
'spl-root'
|
'spl-root'
|
||||||
)
|
)
|
||||||
|
|
||||||
const FOCUS_TREE = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])], [1, 4.6])
|
// Focus is one column of attention: files and review are tabs BEHIND the chat,
|
||||||
|
// the terminal a collapsed rail under it — opening the terminal must never
|
||||||
|
// cover the conversation, which a terminal tab did.
|
||||||
|
const FOCUS_TREE = split(
|
||||||
|
'row',
|
||||||
|
[group(['sessions']), split('column', [group(['workspace', 'files', 'review']), group(['terminal'])], [3, 1])],
|
||||||
|
[1, 4.6]
|
||||||
|
)
|
||||||
|
|
||||||
// Basic starts with sessions and chat so first-run users need not learn
|
// Basic is sessions and chat with the tooling RESTING in its own slots: the
|
||||||
// terminal, files or review panes before using Hermes.
|
// terminal a collapsed rail under the chat (its column carries the chat, so
|
||||||
const BASIC_TREE = split('row', [group(['sessions']), group(['workspace'])], [1, 4.6])
|
// ⌘J folding the right side can never take the rail with it), review and
|
||||||
|
// files a right column that ⌘J / ⌘G open. A tree that simply omitted them was
|
||||||
|
// a lie — applying it adopts every missing pane back in as workspace tabs,
|
||||||
|
// which is Focus.
|
||||||
|
const BASIC_TREE = split(
|
||||||
|
'row',
|
||||||
|
[
|
||||||
|
group(['sessions']),
|
||||||
|
split('column', [group(['workspace']), group(['terminal'])], [3, 1]),
|
||||||
|
split('row', [group(['review']), group(['files'])], [1, 1.2])
|
||||||
|
],
|
||||||
|
[1, 3.4, 1.25]
|
||||||
|
)
|
||||||
|
|
||||||
|
const BASIC_RESTING = ['terminal', 'files', 'review'] as const
|
||||||
|
|
||||||
const TERMINAL_TREE = split(
|
const TERMINAL_TREE = split(
|
||||||
'column',
|
'column',
|
||||||
@@ -64,11 +84,22 @@ const QUAD_TREE = split(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export function registerLayoutPresets() {
|
export function registerLayoutPresets() {
|
||||||
return registry.registerMany([
|
// Simple is always the Basic arrangement; its one choice is which side the
|
||||||
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
|
// sidebar sits. The decks are Advanced — arranging tooling is the point.
|
||||||
...(isOnboardingEnabled() ? [{ id: 'basic', area: 'layouts', title: 'Basic', order: 5, data: BASIC_TREE }] : []),
|
return registerBundledPresets([
|
||||||
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
|
{ id: 'sidebar-left', title: 'Sidebar left', order: 0, tree: BASIC_TREE, resting: BASIC_RESTING, tier: 'simple' },
|
||||||
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
|
{
|
||||||
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
|
id: 'sidebar-right',
|
||||||
|
title: 'Sidebar right',
|
||||||
|
order: 1,
|
||||||
|
tree: mirrorTreeHorizontal(BASIC_TREE),
|
||||||
|
resting: BASIC_RESTING,
|
||||||
|
tier: 'simple'
|
||||||
|
},
|
||||||
|
{ id: 'default', title: 'Default', order: 0, tree: DEFAULT_TREE, tier: 'advanced' },
|
||||||
|
{ id: 'basic', title: 'Basic', order: 5, tree: BASIC_TREE, resting: BASIC_RESTING, tier: 'advanced' },
|
||||||
|
{ id: 'focus', title: 'Focus', order: 10, tree: FOCUS_TREE, resting: ['terminal'], tier: 'advanced' },
|
||||||
|
{ id: 'terminal-deck', title: 'Terminal deck', order: 20, tree: TERMINAL_TREE, tier: 'advanced' },
|
||||||
|
{ id: 'quad', title: 'Quad', order: 30, tree: QUAD_TREE, tier: 'advanced' }
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,55 @@
|
|||||||
|
|
||||||
import { registry } from '@/contrib/registry'
|
import { registry } from '@/contrib/registry'
|
||||||
import { readJson, writeJson, writeKey } from '@/lib/storage'
|
import { readJson, writeJson, writeKey } from '@/lib/storage'
|
||||||
|
import { asLayoutIntent, type Tiered } from '@/store/interface-mode'
|
||||||
|
|
||||||
import { isLayoutNode, type LayoutNode } from './model'
|
import { allPaneIds, findGroupOfPane, isLayoutNode, type LayoutNode } from './model'
|
||||||
import { $layoutTree, applyTree, markActivePreset } from './store'
|
import { $dismissedPanes, $hiddenTreePanes, $layoutTree, applyTree, markActivePreset } from './store'
|
||||||
|
|
||||||
export const LAYOUTS_AREA = 'layouts'
|
export const LAYOUTS_AREA = 'layouts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preset is the tree plus what the tree cannot say. `resting` names the
|
||||||
|
* panes it places but leaves CLOSED — every other pane it places opens on
|
||||||
|
* apply, so a preset states what is on screen. `tier` names the one mode
|
||||||
|
* whose shelf carries it. Both stay off `data`, which every consumer reads as
|
||||||
|
* a bare `LayoutNode`.
|
||||||
|
*/
|
||||||
|
export interface LayoutPresetSpec extends Tiered {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
resting?: readonly string[]
|
||||||
|
title: string
|
||||||
|
tree: LayoutNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const specs = new Map<string, Pick<LayoutPresetSpec, 'resting' | 'tier'>>()
|
||||||
|
|
||||||
|
export function registerBundledPresets(bundled: readonly LayoutPresetSpec[]) {
|
||||||
|
for (const spec of bundled) {
|
||||||
|
rememberSpec(spec.id, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.registerMany(
|
||||||
|
bundled.map(({ id, order, title, tree }) => ({ id, area: LAYOUTS_AREA, title, order, data: tree }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSpec(id: string, { resting, tier }: Pick<LayoutPresetSpec, 'resting' | 'tier'>) {
|
||||||
|
specs.set(id, { resting, tier })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User decks have no tier, so every shelf carries them. */
|
||||||
|
export const layoutPresetTier = (id: string) => specs.get(id)?.tier
|
||||||
|
|
||||||
|
const NO_RESTING: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
export const layoutPresetResting = (id: string): ReadonlySet<string> => {
|
||||||
|
const resting = specs.get(id)?.resting
|
||||||
|
|
||||||
|
return resting ? new Set(resting) : NO_RESTING
|
||||||
|
}
|
||||||
|
|
||||||
// v2: v1 presets predate semantic placement (see store.ts) — retire them.
|
// v2: v1 presets predate semantic placement (see store.ts) — retire them.
|
||||||
const USER_KEY = 'hermes.desktop.layoutPresets.v2'
|
const USER_KEY = 'hermes.desktop.layoutPresets.v2'
|
||||||
|
|
||||||
@@ -23,6 +66,7 @@ writeKey('hermes.desktop.layoutPresets.v1', null)
|
|||||||
|
|
||||||
interface StoredPreset {
|
interface StoredPreset {
|
||||||
name: string
|
name: string
|
||||||
|
resting?: string[]
|
||||||
tree: LayoutNode
|
tree: LayoutNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +79,7 @@ function loadUserPresets(): Record<string, StoredPreset> {
|
|||||||
for (const [id, preset] of Object.entries(parsed)) {
|
for (const [id, preset] of Object.entries(parsed)) {
|
||||||
if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) {
|
if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) {
|
||||||
out[id] = preset
|
out[id] = preset
|
||||||
|
rememberSpec(id, preset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +105,10 @@ for (const [id, preset] of Object.entries(userPresets)) {
|
|||||||
registerUserPreset(id, preset)
|
registerUserPreset(id, preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save any tree as a named user preset (and make it active). */
|
/** Save any tree as a named user preset (and make it active). A deck saved
|
||||||
export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null {
|
* from the live layout remembers which of its panes were closed, so applying
|
||||||
|
* it later restores what was on screen, not just where things sat. */
|
||||||
|
export function saveLayoutPresetTree(name: string, tree: LayoutNode, resting: readonly string[] = []): string | null {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
|
|
||||||
if (!tree || !trimmed) {
|
if (!tree || !trimmed) {
|
||||||
@@ -75,20 +122,27 @@ export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | n
|
|||||||
.replace(/^-+|-+$/g, '') || Date.now().toString(36)
|
.replace(/^-+|-+$/g, '') || Date.now().toString(36)
|
||||||
}`
|
}`
|
||||||
|
|
||||||
userPresets[id] = { name: trimmed, tree }
|
userPresets[id] = { name: trimmed, tree, resting: [...resting] }
|
||||||
persistUserPresets(userPresets)
|
persistUserPresets(userPresets)
|
||||||
|
rememberSpec(id, userPresets[id])
|
||||||
registerUserPreset(id, userPresets[id])
|
registerUserPreset(id, userPresets[id])
|
||||||
markActivePreset(id)
|
markActivePreset(id)
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save the CURRENT tree as a named user preset (and make it active). */
|
/** Save the CURRENT tree as a named user preset (and make it active). A pane
|
||||||
|
* rests when it is closed — hidden, dismissed or folded to its rail — not
|
||||||
|
* when it merely sits behind a sibling tab. */
|
||||||
export function saveCurrentLayoutAs(name: string) {
|
export function saveCurrentLayoutAs(name: string) {
|
||||||
const tree = $layoutTree.get()
|
const tree = $layoutTree.get()
|
||||||
|
|
||||||
if (tree) {
|
if (tree) {
|
||||||
saveLayoutPresetTree(name, tree)
|
const hidden = $hiddenTreePanes.get()
|
||||||
|
const dismissed = $dismissedPanes.get()
|
||||||
|
const rests = (id: string) => hidden.has(id) || dismissed.has(id) || Boolean(findGroupOfPane(tree, id)?.minimized)
|
||||||
|
|
||||||
|
saveLayoutPresetTree(name, tree, allPaneIds(tree).filter(rests))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +152,7 @@ export function deleteUserPreset(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete userPresets[id]
|
delete userPresets[id]
|
||||||
|
specs.delete(id)
|
||||||
persistUserPresets(userPresets)
|
persistUserPresets(userPresets)
|
||||||
userDisposers.get(id)?.()
|
userDisposers.get(id)?.()
|
||||||
userDisposers.delete(id)
|
userDisposers.delete(id)
|
||||||
@@ -105,7 +160,10 @@ export function deleteUserPreset(id: string) {
|
|||||||
|
|
||||||
export const isUserPreset = (id: string) => id in userPresets
|
export const isUserPreset = (id: string) => id in userPresets
|
||||||
|
|
||||||
/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */
|
/** Apply a preset's tree (deep-cloned so live edits never mutate the preset),
|
||||||
|
* opening what it places and resting what it says to. In Simple the mode
|
||||||
|
* already decides what rests, so the open/close side of a layout pick yields
|
||||||
|
* to it instead of surfacing shadowed panes for the session. */
|
||||||
export function applyLayoutPreset(id: string, tree: LayoutNode) {
|
export function applyLayoutPreset(id: string, tree: LayoutNode) {
|
||||||
applyTree(structuredClone(tree), id)
|
asLayoutIntent(() => applyTree(structuredClone(tree), id, specs.get(id)?.resting))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { atom } from 'nanostores'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { registry } from '@/contrib/registry'
|
||||||
|
|
||||||
|
import { allPaneIds, findGroupOfPane, group, split } from './model'
|
||||||
|
import { applyLayoutPreset, deleteUserPreset, layoutPresetTier, registerBundledPresets, saveCurrentLayoutAs } from './presets'
|
||||||
|
import { $dismissedPanes, $hiddenTreePanes, $layoutTree, bindPaneVisibility, bindToolPaneCollapse } from './store'
|
||||||
|
|
||||||
|
// Basic used to be `sessions | workspace`, and picking it produced Focus:
|
||||||
|
// `applyTree` adopts every registered pane the tree omits back in as
|
||||||
|
// workspace tabs. A preset that wants the tooling OFF SCREEN has to place it
|
||||||
|
// and rest it, so the arrangement survives adoption and only what's open
|
||||||
|
// differs from the deck that shows it.
|
||||||
|
|
||||||
|
const ARRANGEMENT = split('row', [
|
||||||
|
group(['sessions']),
|
||||||
|
group(['workspace']),
|
||||||
|
split('column', [group(['review', 'files']), group(['terminal'])])
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposers: (() => void)[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
$dismissedPanes.set(new Set())
|
||||||
|
$hiddenTreePanes.set(new Set())
|
||||||
|
|
||||||
|
for (const [id, data] of [
|
||||||
|
['sessions', { placement: 'left' }],
|
||||||
|
['workspace', { placement: 'main', uncloseable: true }],
|
||||||
|
['files', { placement: 'right' }],
|
||||||
|
['review', { placement: 'right' }],
|
||||||
|
['terminal', { placement: 'bottom', revealOnPreset: true }]
|
||||||
|
] as const) {
|
||||||
|
disposers.push(registry.register({ area: 'panes', data, id, render: () => null, title: id }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
disposers.splice(0).forEach(dispose => dispose())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resting presets', () => {
|
||||||
|
it('keeps the arrangement and closes the resting panes through their stores', () => {
|
||||||
|
const $terminal = atom(true)
|
||||||
|
const $files = atom(true)
|
||||||
|
const $review = atom(true)
|
||||||
|
|
||||||
|
bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true))
|
||||||
|
bindPaneVisibility('files', $files, () => $files.set(false), () => $files.set(true))
|
||||||
|
bindPaneVisibility('review', $review, () => $review.set(false), () => $review.set(true))
|
||||||
|
|
||||||
|
disposers.push(
|
||||||
|
registerBundledPresets([
|
||||||
|
{ id: 'shows', title: 'Shows', order: 0, tree: ARRANGEMENT, tier: 'advanced' },
|
||||||
|
{ id: 'rests', title: 'Rests', order: 1, tree: ARRANGEMENT, resting: ['terminal', 'files', 'review'] }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
applyLayoutPreset('rests', ARRANGEMENT)
|
||||||
|
|
||||||
|
expect(allPaneIds($layoutTree.get()!).sort()).toEqual(allPaneIds(ARRANGEMENT).sort())
|
||||||
|
expect([$terminal.get(), $files.get(), $review.get()]).toEqual([false, false, false])
|
||||||
|
expect([...$hiddenTreePanes.get()].sort()).toEqual(['files', 'review'])
|
||||||
|
expect(findGroupOfPane($layoutTree.get()!, 'terminal')?.minimized).toBe(true)
|
||||||
|
|
||||||
|
// The deck that shows the tooling opens EVERYTHING it places.
|
||||||
|
applyLayoutPreset('shows', ARRANGEMENT)
|
||||||
|
|
||||||
|
expect([$terminal.get(), $files.get(), $review.get()]).toEqual([true, true, true])
|
||||||
|
expect(allPaneIds($layoutTree.get()!).sort()).toEqual(allPaneIds(ARRANGEMENT).sort())
|
||||||
|
|
||||||
|
// Resting again with the store ALREADY closed still collapses the rail:
|
||||||
|
// a same-value closer is a no-op and the fresh tree carries no flag.
|
||||||
|
$terminal.set(false)
|
||||||
|
applyLayoutPreset('rests', ARRANGEMENT)
|
||||||
|
|
||||||
|
expect(findGroupOfPane($layoutTree.get()!, 'terminal')?.minimized).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a deck saved from the live layout remembers what was closed', () => {
|
||||||
|
const $terminal = atom(false)
|
||||||
|
const $files = atom(true)
|
||||||
|
|
||||||
|
bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true))
|
||||||
|
bindPaneVisibility('files', $files, () => $files.set(false), () => $files.set(true))
|
||||||
|
|
||||||
|
disposers.push(registerBundledPresets([{ id: 'seed', title: 'Seed', order: 0, tree: ARRANGEMENT, resting: ['terminal'] }]))
|
||||||
|
applyLayoutPreset('seed', ARRANGEMENT)
|
||||||
|
saveCurrentLayoutAs('Mine')
|
||||||
|
|
||||||
|
// Open the terminal by hand, then re-apply the saved deck: it rests again.
|
||||||
|
$terminal.set(true)
|
||||||
|
applyLayoutPreset('user-mine', $layoutTree.get()!)
|
||||||
|
|
||||||
|
expect($terminal.get()).toBe(false)
|
||||||
|
expect($files.get()).toBe(true)
|
||||||
|
expect(layoutPresetTier('user-mine')).toBeUndefined()
|
||||||
|
|
||||||
|
deleteUserPreset('user-mine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a reveal on apply never steals the tab the preset put first', () => {
|
||||||
|
const $terminal = atom(false)
|
||||||
|
|
||||||
|
bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true))
|
||||||
|
|
||||||
|
// Focus: the terminal is a tab BEHIND the chat, and opening it on apply
|
||||||
|
// used to front it over the chat.
|
||||||
|
const focus = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])])
|
||||||
|
|
||||||
|
disposers.push(registerBundledPresets([{ id: 'focus', title: 'Focus', order: 0, tree: focus }]))
|
||||||
|
applyLayoutPreset('focus', focus)
|
||||||
|
|
||||||
|
expect($terminal.get()).toBe(true)
|
||||||
|
expect(findGroupOfPane($layoutTree.get()!, 'workspace')?.active).toBe('workspace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('files bundled presets by their declared tier; user decks have none', () => {
|
||||||
|
disposers.push(
|
||||||
|
registerBundledPresets([{ id: 'deck', title: 'Deck', order: 0, tree: ARRANGEMENT, tier: 'advanced' }])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(layoutPresetTier('deck')).toBe('advanced')
|
||||||
|
expect(layoutPresetTier('user-mine')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -376,8 +376,8 @@ export function registerPaneCloser(paneId: string, close?: () => void) {
|
|||||||
* Route a pane's "show it" intent through the app store that owns its
|
* Route a pane's "show it" intent through the app store that owns its
|
||||||
* visibility — the mirror of `registerPaneCloser`, so a preset can reveal a
|
* visibility — the mirror of `registerPaneCloser`, so a preset can reveal a
|
||||||
* toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover`
|
* toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover`
|
||||||
* owns) while the toggle stays truthful. Only panes that opt in via
|
* owns) while the toggle stays truthful. Applying a preset opens every pane it
|
||||||
* `data.revealOnPreset` are opened on preset apply.
|
* places, except the ones it places resting.
|
||||||
*/
|
*/
|
||||||
export function registerPaneOpener(paneId: string, open: () => void) {
|
export function registerPaneOpener(paneId: string, open: () => void) {
|
||||||
paneOpeners[paneId] = open
|
paneOpeners[paneId] = open
|
||||||
@@ -1640,7 +1640,7 @@ export function moveTreePane(paneId: string, target: { groupId: string; pos: Dro
|
|||||||
* preset) are adopted into the group their current siblings land in, so
|
* preset) are adopted into the group their current siblings land in, so
|
||||||
* applying a preset never loses a pane.
|
* applying a preset never loses a pane.
|
||||||
*/
|
*/
|
||||||
export function applyTree(tree: LayoutNode, presetId: string) {
|
export function applyTree(tree: LayoutNode, presetId: string, resting: readonly string[] = []) {
|
||||||
const previous = $layoutTree.get()
|
const previous = $layoutTree.get()
|
||||||
|
|
||||||
// A preset defines the layout's SIZES too — stale drag overrides from the
|
// A preset defines the layout's SIZES too — stale drag overrides from the
|
||||||
@@ -1651,21 +1651,46 @@ export function applyTree(tree: LayoutNode, presetId: string) {
|
|||||||
commit(previous ? adoptMissingPanes(tree, previous) : tree)
|
commit(previous ? adoptMissingPanes(tree, previous) : tree)
|
||||||
markActivePreset(presetId)
|
markActivePreset(presetId)
|
||||||
|
|
||||||
// Picking a named layout is an intent to SEE its panes. Toggle-gated panes
|
// A preset says what is ON SCREEN. Every toggle-gated pane it places opens
|
||||||
// (the terminal, whose visibility a store owns) would otherwise stay
|
// through its owning store (so ⌃`/⌘J/⌘G stay truthful), except the ones it
|
||||||
// collapsed after the tree changes — so reveal the ones that opt in through
|
// places RESTING, which close through the same store. Iterate the preset's
|
||||||
// their owning store, keeping the ⌃`/toggle state truthful. Iterate the
|
// DECLARED panes (not the adopted result) so only panes it explicitly places
|
||||||
// preset's DECLARED panes (not the adopted result) so only panes a preset
|
// move.
|
||||||
// explicitly places are turned on.
|
const rests = new Set(resting)
|
||||||
const panes = registry.getArea('panes')
|
|
||||||
|
|
||||||
for (const paneId of allPaneIds(tree)) {
|
for (const paneId of allPaneIds(tree)) {
|
||||||
const data = panes.find(c => c.id === paneId)?.data as { revealOnPreset?: boolean } | undefined
|
if (rests.has(paneId)) {
|
||||||
|
paneClosers[paneId]?.()
|
||||||
|
|
||||||
if (data?.revealOnPreset) {
|
// A store already reading closed makes that closer a same-value no-op,
|
||||||
|
// and the fresh tree carries no minimized flag — so a tool pane's rail
|
||||||
|
// is collapsed explicitly. Hide-style panes need nothing: the hidden
|
||||||
|
// set outlives the tree.
|
||||||
|
if (isCollapsePane(paneId)) {
|
||||||
|
setPaneCollapsed(paneId, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
paneOpeners[paneId]?.()
|
paneOpeners[paneId]?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opening fronts the pane in its stack (a reveal is "show me this"), which
|
||||||
|
// steals the active slot from whatever the preset put first — Focus opened
|
||||||
|
// with the terminal over the chat. The preset's own tab order is the intent:
|
||||||
|
// re-assert each declared group's active tab after the reveals.
|
||||||
|
const applied = $layoutTree.get()
|
||||||
|
|
||||||
|
if (applied) {
|
||||||
|
for (const groupId of groupLeafIds(tree)) {
|
||||||
|
const declared = findGroup(tree, groupId)
|
||||||
|
const want = declared?.active ?? declared?.panes[0]
|
||||||
|
const live = findGroup(applied, groupId)
|
||||||
|
|
||||||
|
if (want && live && live.active !== want && live.panes.includes(want)) {
|
||||||
|
activateTreePane(groupId, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1906,12 +1931,17 @@ export function bindPaneVisibility(
|
|||||||
* wasn't the active tab, the shared-zone branch declined, and the key read as
|
* wasn't the active tab, the shared-zone branch declined, and the key read as
|
||||||
* dead until the stack was broken up. The persisted tree already records which
|
* dead until the stack was broken up. The persisted tree already records which
|
||||||
* tab was active — leave it alone.
|
* tab was active — leave it alone.
|
||||||
|
*
|
||||||
|
* `$rail` says whether a CLOSED pane keeps its rail on screen. Off, the pane
|
||||||
|
* hides instead (Simple has no terminal) — same store, same toggle, only the
|
||||||
|
* resting shape differs. Omitted means always.
|
||||||
*/
|
*/
|
||||||
export function bindToolPaneCollapse(
|
export function bindToolPaneCollapse(
|
||||||
paneId: string,
|
paneId: string,
|
||||||
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
||||||
close: () => void,
|
close: () => void,
|
||||||
open: () => void
|
open: () => void,
|
||||||
|
$rail?: { get(): boolean; listen(fn: (rail: boolean) => void): void }
|
||||||
) {
|
) {
|
||||||
markCollapsePane(paneId)
|
markCollapsePane(paneId)
|
||||||
|
|
||||||
@@ -1922,6 +1952,14 @@ export function bindToolPaneCollapse(
|
|||||||
$open.listen(isOpen => (isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true)))
|
$open.listen(isOpen => (isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true)))
|
||||||
registerPaneCloser(paneId, close)
|
registerPaneCloser(paneId, close)
|
||||||
registerPaneOpener(paneId, open)
|
registerPaneOpener(paneId, open)
|
||||||
|
|
||||||
|
if ($rail) {
|
||||||
|
const sync = () => setTreePaneHidden(paneId, !$open.get() && !$rail.get())
|
||||||
|
|
||||||
|
sync()
|
||||||
|
$open.listen(sync)
|
||||||
|
$rail.listen(sync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
42
apps/desktop/src/store/layout-flipped-sides.test.ts
Normal file
42
apps/desktop/src/store/layout-flipped-sides.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// An edge belongs to the pane that sits on it. Mirrored (⌘\ or the Simple
|
||||||
|
// "Sidebar right" layout), the sessions sidebar is on the RIGHT — ⌘B must
|
||||||
|
// still mean the sidebar and ⌘J the file tree, or a resting file tree folds
|
||||||
|
// the sidebar away with it. Regression for the "Sidebar right" layout
|
||||||
|
// rendering only the chat.
|
||||||
|
|
||||||
|
describe('side toggles follow the flip', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses the edge the sidebar / file tree actually sit on', async () => {
|
||||||
|
const tree = await import('@/components/pane-shell/tree/store')
|
||||||
|
const layout = await import('@/store/layout')
|
||||||
|
|
||||||
|
expect(layout.sidebarSide()).toBe('left')
|
||||||
|
expect(layout.fileBrowserSide()).toBe('right')
|
||||||
|
|
||||||
|
layout.$panesFlipped.set(true)
|
||||||
|
|
||||||
|
expect(layout.sidebarSide()).toBe('right')
|
||||||
|
expect(layout.fileBrowserSide()).toBe('left')
|
||||||
|
|
||||||
|
layout.setFileBrowserOpen(false)
|
||||||
|
expect(tree.$collapsedTreeSides.get().has('left')).toBe(true)
|
||||||
|
expect(tree.$collapsedTreeSides.get().has('right')).toBe(false)
|
||||||
|
|
||||||
|
layout.setSidebarOpen(false)
|
||||||
|
expect(tree.$collapsedTreeSides.get().has('right')).toBe(true)
|
||||||
|
|
||||||
|
layout.setFileBrowserOpen(true)
|
||||||
|
layout.setSidebarOpen(true)
|
||||||
|
expect(tree.$collapsedTreeSides.get().size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,12 +5,14 @@ import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
|||||||
import {
|
import {
|
||||||
restoreHiddenTreeSideTabs,
|
restoreHiddenTreeSideTabs,
|
||||||
restoreMinimizedTreeSide,
|
restoreMinimizedTreeSide,
|
||||||
setTreeSideCollapsed
|
setTreeSideCollapsed,
|
||||||
|
type TreeSide
|
||||||
} from '@/components/pane-shell/tree/store'
|
} from '@/components/pane-shell/tree/store'
|
||||||
import { matchesQuery } from '@/hooks/use-media-query'
|
import { matchesQuery } from '@/hooks/use-media-query'
|
||||||
import { connectionScopedAtom } from '@/lib/connection-scoped'
|
import { connectionScopedAtom } from '@/lib/connection-scoped'
|
||||||
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
|
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
|
||||||
import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage'
|
import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage'
|
||||||
|
import { modeBound } from '@/store/interface-mode'
|
||||||
|
|
||||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes'
|
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes'
|
||||||
import { $showAllProfiles, setShowAllProfiles } from './profile'
|
import { $showAllProfiles, setShowAllProfiles } from './profile'
|
||||||
@@ -78,11 +80,18 @@ export const $sidebarOpen: ReadableAtom<boolean> = computed(
|
|||||||
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
|
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
|
// The file tree's own toggle (⌘J), which doubles as the RIGHT side's collapse.
|
||||||
|
// Simple mode rests it closed without touching the pane record; ⌘J still opens
|
||||||
|
// it for the session.
|
||||||
|
const $fileBrowserOpenPref: ReadableAtom<boolean> = computed(
|
||||||
$paneStates,
|
$paneStates,
|
||||||
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const $fileBrowserOpen = modeBound('fileBrowserOpen', $fileBrowserOpenPref, open =>
|
||||||
|
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
||||||
|
)
|
||||||
|
|
||||||
// Persisted so a relaunch reopens the same rail tab. Null when the rail has no
|
// Persisted so a relaunch reopens the same rail tab. Null when the rail has no
|
||||||
// tabs; a restored id with no matching tab is reconciled in the preview store.
|
// tabs; a restored id with no matching tab is reconciled in the preview store.
|
||||||
export const $rightRailActiveTabId = persistentAtom<RightRailTabId | null>(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, {
|
export const $rightRailActiveTabId = persistentAtom<RightRailTabId | null>(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, {
|
||||||
@@ -316,12 +325,16 @@ const $sidebarSortKey = persistentAtom<SidebarSortKey>(
|
|||||||
oneOf(SIDEBAR_SORT_KEYS, 'updated')
|
oneOf(SIDEBAR_SORT_KEYS, 'updated')
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(
|
// Simple mode rests the rows on what was said and when, without touching
|
||||||
|
// this preference.
|
||||||
|
const $sidebarRowMetaPref = persistentAtom<SidebarRowMeta[]>(
|
||||||
SIDEBAR_ROW_META_STORAGE_KEY,
|
SIDEBAR_ROW_META_STORAGE_KEY,
|
||||||
SIDEBAR_DEFAULT_ROW_META,
|
SIDEBAR_DEFAULT_ROW_META,
|
||||||
listOf(ROW_META)
|
listOf(ROW_META)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const $sidebarRowMeta = modeBound('sidebarRowMeta', $sidebarRowMetaPref, meta => $sidebarRowMetaPref.set(meta))
|
||||||
|
|
||||||
/** Inbox style: render the flat list's session rows as three-line cards
|
/** Inbox style: render the flat list's session rows as three-line cards
|
||||||
* (project · age / title / model · size) instead of the one-line row. A
|
* (project · age / title / model · size) instead of the one-line row. A
|
||||||
* RENDER variant, deliberately not a grouping — it composes with whichever
|
* RENDER variant, deliberately not a grouping — it composes with whichever
|
||||||
@@ -534,13 +547,19 @@ function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolea
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An edge belongs to the pane that sits on it: the flip (⌘\ / a mirrored
|
||||||
|
// layout) puts the sessions sidebar on the right, and ⌘B keeps meaning the
|
||||||
|
// sidebar, ⌘J the file tree — never "whatever is on the left".
|
||||||
|
export const sidebarSide = (): TreeSide => ($panesFlipped.get() ? 'right' : 'left')
|
||||||
|
export const fileBrowserSide = (): TreeSide => ($panesFlipped.get() ? 'left' : 'right')
|
||||||
|
|
||||||
export function setSidebarOpen(open: boolean) {
|
export function setSidebarOpen(open: boolean) {
|
||||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||||
setTreeSideCollapsed('left', !open)
|
setTreeSideCollapsed(sidebarSide(), !open)
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreMinimizedTreeSide('left')
|
restoreMinimizedTreeSide(sidebarSide())
|
||||||
restoreHiddenTreeSideTabs('left')
|
restoreHiddenTreeSideTabs(sidebarSide())
|
||||||
}
|
}
|
||||||
|
|
||||||
revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close')
|
revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close')
|
||||||
@@ -548,9 +567,9 @@ export function setSidebarOpen(open: boolean) {
|
|||||||
|
|
||||||
export function toggleSidebarOpen() {
|
export function toggleSidebarOpen() {
|
||||||
if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) {
|
if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) {
|
||||||
const open = restoreMinimizedTreeSide('left') || !$sidebarOpen.get()
|
const open = restoreMinimizedTreeSide(sidebarSide()) || !$sidebarOpen.get()
|
||||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||||
setTreeSideCollapsed('left', !open)
|
setTreeSideCollapsed(sidebarSide(), !open)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,18 +578,18 @@ export function toggleFileBrowserOpen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = restoreMinimizedTreeSide('right') || !$fileBrowserOpen.get()
|
const open = restoreMinimizedTreeSide(fileBrowserSide()) || !$fileBrowserOpen.get()
|
||||||
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
$fileBrowserOpen.set(open)
|
||||||
setTreeSideCollapsed('right', !open)
|
setTreeSideCollapsed(fileBrowserSide(), !open)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFileBrowserOpen(open: boolean) {
|
export function setFileBrowserOpen(open: boolean) {
|
||||||
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
$fileBrowserOpen.set(open)
|
||||||
setTreeSideCollapsed('right', !open)
|
setTreeSideCollapsed(fileBrowserSide(), !open)
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreMinimizedTreeSide('right')
|
restoreMinimizedTreeSide(fileBrowserSide())
|
||||||
restoreHiddenTreeSideTabs('right')
|
restoreHiddenTreeSideTabs(fileBrowserSide())
|
||||||
}
|
}
|
||||||
|
|
||||||
revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close')
|
revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close')
|
||||||
|
|||||||
Reference in New Issue
Block a user