feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf `<Text>`, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
||||
import { describeCompactTimeAgo } from "@/utils/time";
|
||||
|
||||
/**
|
||||
* A compact relative timestamp that keeps itself current.
|
||||
*
|
||||
* Call this from the smallest component that renders the text. The state lives there, so a tick
|
||||
* re-renders one `<Text>` and nothing above it — the row, the list, and the sidebar are never
|
||||
* involved.
|
||||
*
|
||||
* Two things keep the cost near zero:
|
||||
*
|
||||
* - **Ticking is not re-rendering.** A tick recomputes the label and only sets state if the
|
||||
* string actually changed. A row reading "3d" is woken hourly and re-renders roughly once a
|
||||
* day, when it becomes "4d".
|
||||
* - **The tier follows the label.** As a timestamp ages the label changes more slowly, so the
|
||||
* subscription moves to a slower tier; past a week it unsubscribes for good, because a date
|
||||
* never changes again.
|
||||
*/
|
||||
export function useCompactTimeAgo(date: Date | null): string {
|
||||
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
|
||||
|
||||
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
|
||||
// depending on identity would tear down and rebuild the subscription for an unchanged time.
|
||||
const time = date === null ? null : date.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
if (time === null) {
|
||||
setLabel("");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = new Date(time);
|
||||
let current = describeCompactTimeAgo(source);
|
||||
setLabel(current.label);
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleTick = () => {
|
||||
const next = describeCompactTimeAgo(source);
|
||||
if (next.label !== current.label) {
|
||||
setLabel(next.label);
|
||||
}
|
||||
if (next.resolution !== current.resolution) {
|
||||
// Aged into a slower tier — or out of them entirely.
|
||||
current = next;
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
attach();
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
if (current.resolution === "static") return;
|
||||
unsubscribe = subscribeToRelativeTimeTick(current.resolution as TickResolution, handleTick);
|
||||
};
|
||||
|
||||
attach();
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [time]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings,
|
||||
type SidebarWorkspaceTrailing,
|
||||
type SettingsDeps,
|
||||
type WorkspaceTitleSource,
|
||||
} from "./storage";
|
||||
@@ -69,9 +70,27 @@ export type {
|
||||
ServiceUrlBehavior,
|
||||
Settings,
|
||||
SettingsDeps,
|
||||
SidebarWorkspaceTrailing,
|
||||
WorkspaceTitleSource,
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a `Settings` patch into the part the app owns. The two halves persist to different
|
||||
* places (AsyncStorage vs the Electron settings bridge), and the app's half is exactly the
|
||||
* key set of `DEFAULT_CLIENT_SETTINGS` — reading the keys off it means a new app setting
|
||||
* flows through here without anyone remembering to widen a hand-written list.
|
||||
*/
|
||||
function pickDefinedAppSettings(updates: Partial<Settings>): Partial<AppSettings> {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
for (const key of Object.keys(DEFAULT_CLIENT_SETTINGS) as (keyof AppSettings)[]) {
|
||||
const value = updates[key];
|
||||
if (value !== undefined) {
|
||||
Object.assign(appUpdates, { [key]: value });
|
||||
}
|
||||
}
|
||||
return appUpdates;
|
||||
}
|
||||
|
||||
const productionDeps: SettingsDeps = {
|
||||
storage: AsyncStorage,
|
||||
desktop: {
|
||||
@@ -151,49 +170,7 @@ export function useSettings<TSelected>(
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<Settings>) => {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
if (updates.theme !== undefined) {
|
||||
appUpdates.theme = updates.theme;
|
||||
}
|
||||
if (updates.language !== undefined) {
|
||||
appUpdates.language = updates.language;
|
||||
}
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
if (updates.terminalScrollbackLines !== undefined) {
|
||||
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||
}
|
||||
if (updates.uiFontFamily !== undefined) {
|
||||
appUpdates.uiFontFamily = updates.uiFontFamily;
|
||||
}
|
||||
if (updates.monoFontFamily !== undefined) {
|
||||
appUpdates.monoFontFamily = updates.monoFontFamily;
|
||||
}
|
||||
if (updates.uiFontSize !== undefined) {
|
||||
appUpdates.uiFontSize = updates.uiFontSize;
|
||||
}
|
||||
if (updates.codeFontSize !== undefined) {
|
||||
appUpdates.codeFontSize = updates.codeFontSize;
|
||||
}
|
||||
if (updates.syntaxTheme !== undefined) {
|
||||
appUpdates.syntaxTheme = updates.syntaxTheme;
|
||||
}
|
||||
if (updates.workspaceTitleSource !== undefined) {
|
||||
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
|
||||
}
|
||||
if (updates.autoExpandReasoning !== undefined) {
|
||||
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
|
||||
}
|
||||
if (updates.toolCallDetailLevel !== undefined) {
|
||||
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
|
||||
}
|
||||
if (updates.vimKeybindings !== undefined) {
|
||||
appUpdates.vimKeybindings = updates.vimKeybindings;
|
||||
}
|
||||
const appUpdates = pickDefinedAppSettings(updates);
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
|
||||
@@ -2,6 +2,11 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
parseSidebarRowItems,
|
||||
type SidebarRowItems,
|
||||
} from "@/components/sidebar/display-preferences/row-items";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
@@ -12,11 +17,18 @@ export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
export type WorkspaceTitleSource = "title" | "branch";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
||||
"diff",
|
||||
"timestamp",
|
||||
"none",
|
||||
]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
@@ -42,6 +54,8 @@ export interface AppSettings {
|
||||
codeFontSize: number; // clamped px, default 12
|
||||
syntaxTheme: SyntaxThemeId; // default "one"
|
||||
workspaceTitleSource: WorkspaceTitleSource;
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
@@ -68,6 +82,8 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
syntaxTheme: "one",
|
||||
workspaceTitleSource: "title",
|
||||
sidebarWorkspaceTrailing: "diff",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
@@ -206,15 +222,16 @@ function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings>
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
/**
|
||||
* The settings whose stored value only has to be a member of a fixed set. Grouped like the
|
||||
* boolean settings are: the numeric and font settings need real parsing and clamping, these
|
||||
* need a membership check and nothing else.
|
||||
*/
|
||||
function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
const language = parseAppLanguage(stored.language);
|
||||
if (language !== null) {
|
||||
result.language = language;
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
@@ -224,6 +241,34 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
if (
|
||||
typeof stored.workspaceTitleSource === "string" &&
|
||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||
) {
|
||||
result.workspaceTitleSource = stored.workspaceTitleSource;
|
||||
}
|
||||
if (
|
||||
typeof stored.sidebarWorkspaceTrailing === "string" &&
|
||||
VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing)
|
||||
) {
|
||||
result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
Object.assign(result, pickEnumAppSettings(stored));
|
||||
if (stored.sidebarRowItems !== undefined) {
|
||||
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
|
||||
}
|
||||
const language = parseAppLanguage(stored.language);
|
||||
if (language !== null) {
|
||||
result.language = language;
|
||||
}
|
||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
@@ -250,16 +295,7 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
if (codeFontSize !== null) {
|
||||
result.codeFontSize = codeFontSize;
|
||||
}
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
Object.assign(result, pickBooleanAppSettings(stored));
|
||||
if (
|
||||
typeof stored.workspaceTitleSource === "string" &&
|
||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||
) {
|
||||
result.workspaceTitleSource = stored.workspaceTitleSource;
|
||||
}
|
||||
if (typeof stored.autoExpandReasoning === "boolean") {
|
||||
result.autoExpandReasoning = stored.autoExpandReasoning;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user