diff --git a/package.json b/package.json index bc0d4091..96be27b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus-code", "productName": "NexusCode", - "version": "0.7.1", + "version": "0.8.0", "description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.", "license": "MIT", "private": true, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 49740f03..b09024ae 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -29,6 +29,7 @@ import { AddWorkspaceDialog } from "./components/workspace/add-workspace"; import { WorkspacePanel } from "./components/workspace/panel"; import { showRemoveWorkspaceConfirm } from "./components/workspace/remove-workspace-dialog"; import { useThemeEffect } from "./hooks/use-theme-effect"; +import { useInactivePanelDimEffect } from "./hooks/use-inactive-panel-dim-effect"; import { useWindowOpacityEffect } from "./hooks/use-window-opacity-effect"; import { ipcCallResult } from "./ipc/client"; import { useGlobalKeybindings } from "./keybindings/use-global-keybindings"; @@ -42,6 +43,7 @@ import { useSettingsUIStore } from "./state/stores/settings-ui"; import { useTerminalStore } from "./state/stores/terminal"; import { useThemeStore } from "./state/stores/theme"; import { useUIStore } from "./state/stores/ui"; +import { useInactivePanelDimStore } from "./state/stores/inactive-panel-dim"; import { useWindowOpacityStore } from "./state/stores/window-opacity"; import { useWorkspacesStore } from "./state/stores/workspaces"; @@ -75,6 +77,7 @@ export function App() { const iconThemePreference = useIconThemeStore((s) => s.preference); const themePreference = useThemeStore((s) => s.preference); const opacity = useWindowOpacityStore((s) => s.opacity); + const inactivePanelDim = useInactivePanelDimStore((s) => s.dim); const editorFontSize = useEditorFontStore((s) => s.size); const editorFontFamily = useEditorFontStore((s) => s.family); const editorFontLigatures = useEditorFontStore((s) => s.ligatures); @@ -88,6 +91,7 @@ export function App() { iconThemePreference: typeof iconThemePreference; themePreference: typeof themePreference; opacity: number; + inactivePanelDim: number; editorFontSize: typeof editorFontSize; editorFontFamily: typeof editorFontFamily; editorFontLigatures: typeof editorFontLigatures; @@ -109,6 +113,7 @@ export function App() { iconThemePreference, themePreference, opacity, + inactivePanelDim, editorFontSize, editorFontFamily, editorFontLigatures, @@ -129,7 +134,8 @@ export function App() { snap !== null && (iconThemePreference !== snap.iconThemePreference || themePreference !== snap.themePreference || - opacity !== snap.opacity); + opacity !== snap.opacity || + inactivePanelDim !== snap.inactivePanelDim); const editorDirty = snap !== null && (editorFontSize !== snap.editorFontSize || @@ -147,7 +153,7 @@ export function App() { id: "appearance", label: t("nav.appearance"), group: t("nav.group.settings"), - keywords: ["theme", "opacity", "language", "언어"], + keywords: ["theme", "opacity", "language", "dim", "inactive", "panel", "언어", "흐림"], dirty: appearanceDirty, }, { @@ -195,6 +201,7 @@ export function App() { iconThemePreference, themePreference, opacity, + inactivePanelDim, editorFontSize, editorFontFamily, editorFontLigatures, @@ -359,6 +366,7 @@ export function App() { // Apply --window-opacity CSS property to documentElement. useWindowOpacityEffect(); + useInactivePanelDimEffect(); // Wire the keyboard dispatcher and the Application Menu IPC bridge to // the same command registry. Both surfaces resolve to one diff --git a/src/renderer/bootstrap.ts b/src/renderer/bootstrap.ts index a41df69d..5653eab4 100644 --- a/src/renderer/bootstrap.ts +++ b/src/renderer/bootstrap.ts @@ -33,6 +33,7 @@ import { useTerminalStore } from "./state/stores/terminal"; import { useThemeStore } from "./state/stores/theme"; import { useUIStore } from "./state/stores/ui"; import { useUpdatesStore } from "./state/stores/updates"; +import { useInactivePanelDimStore } from "./state/stores/inactive-panel-dim"; import { useWindowOpacityStore } from "./state/stores/window-opacity"; import { initializeWorkspaceLifecycle } from "./state/workspace-cleanup"; @@ -186,6 +187,9 @@ export async function bootstrapAppState(): Promise { // Hydrate window opacity from appState (authoritative store). useWindowOpacityStore.getState().hydrate(state.windowOpacity); + // Hydrate inactive-panel dim multiplier from appState (authoritative store). + useInactivePanelDimStore.getState().hydrate(state.inactivePanelDim); + // Hydrate update preferences (channel + auto-check toggle) from appState // and install the statusChanged listener. useUpdatesStore.getState().hydrate({ diff --git a/src/renderer/components/files/file-tree/row.tsx b/src/renderer/components/files/file-tree/row.tsx index e00f684f..d3c2ecaf 100644 --- a/src/renderer/components/files/file-tree/row.tsx +++ b/src/renderer/components/files/file-tree/row.tsx @@ -58,8 +58,14 @@ interface FileTreeRowProps { isIgnored?: boolean; /** True when this row is in the cut clipboard (VSCode parity: dimmed). */ isCut?: boolean; - onToggle: () => void; // dir click - onClick: (e: React.MouseEvent) => void; // file click + /** + * Row click. Always receives the event so the parent's handler can read + * shift/cmd modifiers for range/toggle multi-selection. Applies uniformly + * to files AND folders — the parent (handleRowClick) decides the primary + * action: a plain (unmodified) folder click toggles expand, a plain file + * click opens it, while modified clicks extend/toggle the selection set. + */ + onClick: (e: React.MouseEvent) => void; /** * File-only double-click. Mirrors VSCode explorer's "double-click = * open as a permanent (non-preview) tab" gesture. @@ -91,7 +97,6 @@ export function FileTreeRow({ decoration, isIgnored = false, isCut = false, - onToggle, onClick, onDoubleClick, onContextMenu, @@ -174,7 +179,7 @@ export function FileTreeRow({ aria-level={depth + 1} aria-expanded={isDir ? isExpanded : undefined} aria-selected={isSelected} - onClick={isDir ? onToggle : (e) => onClick(e)} + onClick={(e) => onClick(e)} onDoubleClick={isDir ? undefined : onDoubleClick} onContextMenu={onContextMenu} title={node.name} diff --git a/src/renderer/components/files/file-tree/virtual-body.tsx b/src/renderer/components/files/file-tree/virtual-body.tsx index 5206a020..a470e26c 100644 --- a/src/renderer/components/files/file-tree/virtual-body.tsx +++ b/src/renderer/components/files/file-tree/virtual-body.tsx @@ -158,7 +158,6 @@ export function FileTreeVirtualBody({ isLoading={tree?.loading.has(item.absPath) ?? false} decoration={decoration} isIgnored={isIgnored} - onToggle={() => onRowClick(flatIdx, item)} onClick={(e) => onRowClick(flatIdx, item, e)} onDoubleClick={() => onRowDoubleClick(flatIdx, item)} onContextMenu={() => onRowContextMenu(flatIdx, item)} diff --git a/src/renderer/components/settings/panels/appearance-panel.tsx b/src/renderer/components/settings/panels/appearance-panel.tsx index 1dc37497..30f350fc 100644 --- a/src/renderer/components/settings/panels/appearance-panel.tsx +++ b/src/renderer/components/settings/panels/appearance-panel.tsx @@ -41,6 +41,12 @@ import type { SupportedLanguage } from "../../../../shared/i18n"; import { cn } from "@/utils/cn"; import { type IconTheme, useIconThemeStore } from "../../../state/stores/icon-theme"; import { useLanguageStore } from "../../../state/stores/language"; +import { + INACTIVE_PANEL_DIM_DEFAULT, + INACTIVE_PANEL_DIM_MAX, + INACTIVE_PANEL_DIM_MIN, + useInactivePanelDimStore, +} from "../../../state/stores/inactive-panel-dim"; import { useThemeStore } from "../../../state/stores/theme"; import { useWindowOpacityStore } from "../../../state/stores/window-opacity"; import { SettingsSection } from "../section"; @@ -55,6 +61,10 @@ const OPACITY_MIN = 0; const OPACITY_MAX = 1.0; const OPACITY_STEP = 0.05; +// Inactive-panel dim — multiplier on each theme's tuned veil alpha. 1 (100%) = +// theme default, 0 = no dim, 2 (200%) = double. Slider steps in 5% increments. +const DIM_STEP = 0.05; + // Language options — endonym labels, fixed regardless of the active UI locale. // Rule: label is the language's own native name; never translated. // Threshold: ≤4 options → SegmentedControl, >4 → token-sealed Select. @@ -94,20 +104,29 @@ export function AppearancePanel() { const opacity = useWindowOpacityStore((s) => s.opacity); const setOpacity = useWindowOpacityStore((s) => s.setOpacity); + const dim = useInactivePanelDimStore((s) => s.dim); + const setDim = useInactivePanelDimStore((s) => s.setDim); + // Local preview — updated on every drag tick for real-time value label. const [localOpacity, setLocalOpacity] = useState(opacity); + const [localDim, setLocalDim] = useState(dim); // Keep local preview in sync when the store changes outside this component // (e.g. hydration, external restore, dialog re-open with fresh store value). useEffect(() => { setLocalOpacity(opacity); }, [opacity]); + useEffect(() => { + setLocalDim(dim); + }, [dim]); const opacityPercent = Math.round(localOpacity * 100); + const dimPercent = Math.round(localDim * 100); const iconThemeDirty = iconThemePreference !== DEFAULT_ICON_THEME; const themeDirty = themePreference !== DEFAULT_THEME; const opacityDirty = opacity !== 1; + const dimDirty = dim !== INACTIVE_PANEL_DIM_DEFAULT; const languageLabel = t("appearance.language"); @@ -215,6 +234,46 @@ export function AppearancePanel() { + + {/* Section: Inactive Panel Dimming — multiplier on the theme's veil alpha. + 100% = theme default, 0% = no dim, up to 200% = double. Applies + immediately via --inactive-panel-dim CSS var (no restart). */} + setDim(INACTIVE_PANEL_DIM_DEFAULT)} + > +
+ { + if (vals[0] !== undefined) { + setLocalDim(vals[0]); + setDim(vals[0]); + } + }} + aria-label={t("appearance.inactivePanelDim")} + className="relative flex flex-1 touch-none select-none items-center" + > + + + + + + + {dimPercent}% + +
+
); } diff --git a/src/renderer/hooks/use-inactive-panel-dim-effect.ts b/src/renderer/hooks/use-inactive-panel-dim-effect.ts new file mode 100644 index 00000000..a4524473 --- /dev/null +++ b/src/renderer/hooks/use-inactive-panel-dim-effect.ts @@ -0,0 +1,23 @@ +// src/renderer/hooks/use-inactive-panel-dim-effect.ts — Applies the inactive-panel +// dim multiplier to the DOM. +// +// Subscribes to the inactive-panel-dim store and sets the --inactive-panel-dim +// CSS custom property on documentElement whenever it changes. +// +// The property is consumed by the --surface-island-inactive-veil token in the +// generated theme CSS via calc(): the veil's alpha is `themeDefaultAlpha * +// var(--inactive-panel-dim, 1)`. At 1 (the default) the veil renders with each +// theme's tuned alpha; 0 removes the dim entirely; 2 doubles it. +// +// Called once in App.tsx alongside useWindowOpacityEffect(). + +import { useEffect } from "react"; +import { useInactivePanelDimStore } from "../state/stores/inactive-panel-dim"; + +export function useInactivePanelDimEffect(): void { + const dim = useInactivePanelDimStore((s) => s.dim); + + useEffect(() => { + document.documentElement.style.setProperty("--inactive-panel-dim", String(dim)); + }, [dim]); +} diff --git a/src/renderer/state/stores/inactive-panel-dim.ts b/src/renderer/state/stores/inactive-panel-dim.ts new file mode 100644 index 00000000..4d69c09d --- /dev/null +++ b/src/renderer/state/stores/inactive-panel-dim.ts @@ -0,0 +1,95 @@ +// src/renderer/state/stores/inactive-panel-dim.ts — Inactive-panel dim preference store. +// +// Mirrors the pattern established by state/stores/window-opacity.ts. +// +// Persistence model: +// - appState (main process, via IPC) — authoritative store. +// - localStorage key "inactivePanelDim" — boot cache, read synchronously +// before first paint so the dim level is correct on the first frame. +// +// Semantics: a MULTIPLIER on each theme's tuned inactive-veil alpha (dark +// themes overlay white @0.2, light themes overlay black @0.04 — see +// theme-adapter.ts). 1 = theme default (no change), 0 = no dim, 2 = double the +// default strength. Applied at runtime via useInactivePanelDimEffect → +// --inactive-panel-dim CSS var → the veil token's calc() in the generated +// theme CSS. Takes effect immediately — no restart required. + +import { create } from "zustand"; +import { createLogger } from "../../../shared/log/renderer"; +import { ipcCallResult } from "../../ipc/client"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const log = createLogger("inactive-panel-dim"); + +const INACTIVE_PANEL_DIM_STORAGE_KEY = "inactivePanelDim"; + +/** Theme-default multiplier — 1 means "use each theme's tuned veil alpha". */ +export const INACTIVE_PANEL_DIM_DEFAULT = 1; +/** Slider range: 0 = no dim, 2 = double the theme-default strength. */ +export const INACTIVE_PANEL_DIM_MIN = 0; +export const INACTIVE_PANEL_DIM_MAX = 2; + +// --------------------------------------------------------------------------- +// State shape +// --------------------------------------------------------------------------- + +interface InactivePanelDimState { + /** Multiplier applied to the theme's inactive-veil alpha. Range: [0, 2]. */ + dim: number; + + /** Hydrate from persisted appState — called once during bootstrap. */ + hydrate(dim: number | undefined): void; + + /** + * Set the inactive-panel dim multiplier. + * Persists to localStorage (boot cache) + appState (authoritative store). + */ + setDim(dim: number): void; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export const useInactivePanelDimStore = create((set) => { + // Derive initial value from localStorage (written on a prior session). Falls + // back to the theme default (1) if absent/invalid/out-of-range. + const storedRaw = + typeof localStorage !== "undefined" + ? localStorage.getItem(INACTIVE_PANEL_DIM_STORAGE_KEY) + : null; + const parsed = storedRaw !== null ? parseFloat(storedRaw) : Number.NaN; + const initialDim = + !Number.isNaN(parsed) && parsed >= INACTIVE_PANEL_DIM_MIN && parsed <= INACTIVE_PANEL_DIM_MAX + ? parsed + : INACTIVE_PANEL_DIM_DEFAULT; + + return { + dim: initialDim, + + hydrate(dim) { + const value = dim ?? INACTIVE_PANEL_DIM_DEFAULT; + set({ dim: value }); + if (typeof localStorage !== "undefined") { + localStorage.setItem(INACTIVE_PANEL_DIM_STORAGE_KEY, String(value)); + } + }, + + setDim(dim) { + set({ dim }); + if (typeof localStorage !== "undefined") { + localStorage.setItem(INACTIVE_PANEL_DIM_STORAGE_KEY, String(dim)); + } + // Authoritative write — fire-and-forget; the next boot's hydrate() will + // re-read whatever made it to disk. Errors are logged only. + void ipcCallResult("appState", "set", { + inactivePanelDim: dim === INACTIVE_PANEL_DIM_DEFAULT ? undefined : dim, + }).then((result) => { + if (!result.ok) log.warn(`appState set failed: ${result.message}`); + }); + }, + }; +}); diff --git a/src/renderer/styles/theme.generated.css b/src/renderer/styles/theme.generated.css index 258de153..a6fffbc2 100644 --- a/src/renderer/styles/theme.generated.css +++ b/src/renderer/styles/theme.generated.css @@ -198,7 +198,7 @@ --surface-island-bg: #0d1117; --surface-island-fg: #c9d1d9; --surface-island-border: #30363d; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #30363d; --state-drag-indicator: #1f6feb; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -330,7 +330,7 @@ --surface-island-bg: #0d1117; --surface-island-fg: #c9d1d9; --surface-island-border: #30363d; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #30363d; --state-drag-indicator: #1f6feb; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -455,7 +455,7 @@ --surface-island-bg: #ffffff; --surface-island-fg: #1f2328; --surface-island-border: #d0d7de; - --surface-island-inactive-veil: rgba(0, 0, 0, 0.04); + --surface-island-inactive-veil: rgba(0, 0, 0, calc(0.04 * var(--inactive-panel-dim, 1))); --surface-floating-border: #d0d7de; --state-drag-indicator: #0969da; --state-drop-target-bg: rgba(0, 0, 0, 0.08); @@ -580,7 +580,7 @@ --surface-island-bg: #282a36; --surface-island-fg: #f8f8f2; --surface-island-border: #191a21; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #191a21; --state-drag-indicator: #bd93f9; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -705,7 +705,7 @@ --surface-island-bg: #282c34; --surface-island-fg: #abb2bf; --surface-island-border: #181a1f; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #181a1f; --state-drag-indicator: #61afef; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -830,7 +830,7 @@ --surface-island-bg: #272822; --surface-island-fg: #f8f8f2; --surface-island-border: #1e1f1c; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #1e1f1c; --state-drag-indicator: #a6e22e; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -955,7 +955,7 @@ --surface-island-bg: #1a1b26; --surface-island-fg: #c0caf5; --surface-island-border: #15161e; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #15161e; --state-drag-indicator: #7aa2f7; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -1080,7 +1080,7 @@ --surface-island-bg: #002b36; --surface-island-fg: #839496; --surface-island-border: #073642; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #073642; --state-drag-indicator: #268bd2; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -1205,7 +1205,7 @@ --surface-island-bg: #2e3440; --surface-island-fg: #d8dee9; --surface-island-border: #3b4252; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #3b4252; --state-drag-indicator: #88c0d0; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -1330,7 +1330,7 @@ --surface-island-bg: #1e1e2e; --surface-island-fg: #cdd6f4; --surface-island-border: #181825; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #181825; --state-drag-indicator: #89b4fa; --state-drop-target-bg: rgba(255, 255, 255, 0.08); @@ -1455,7 +1455,7 @@ --surface-island-bg: #282828; --surface-island-fg: #ebdbb2; --surface-island-border: #3c3836; - --surface-island-inactive-veil: rgba(255, 255, 255, 0.2); + --surface-island-inactive-veil: rgba(255, 255, 255, calc(0.2 * var(--inactive-panel-dim, 1))); --surface-floating-border: #3c3836; --state-drag-indicator: #fabd2f; --state-drop-target-bg: rgba(255, 255, 255, 0.08); diff --git a/src/shared/design-tokens/theme-adapter.ts b/src/shared/design-tokens/theme-adapter.ts index 557ab42b..32563cdf 100644 --- a/src/shared/design-tokens/theme-adapter.ts +++ b/src/shared/design-tokens/theme-adapter.ts @@ -165,6 +165,22 @@ function overlay(base: "dark" | "light", alpha: number): string { return `rgba(${rgb}, ${alpha})`; } +/** + * Overlay whose alpha is scaled at runtime by a CSS custom property. + * Emits `rgba(rgb, calc( * var(, )))` so the baked + * per-theme alpha stays the default while a user setting can scale it live. + * Used by surface.island.inactive.veil + the --inactive-panel-dim preference. + */ +function overlayScaled( + base: "dark" | "light", + alpha: number, + cssVar: string, + fallback: number, +): string { + const rgb = base === "dark" ? "255, 255, 255" : "0, 0, 0"; + return `rgba(${rgb}, calc(${alpha} * var(${cssVar}, ${fallback})))`; +} + /** * Apply an alpha multiplier to any CSS color string. * Returns an rgba() string parsed from the original color; falls back to @@ -228,7 +244,14 @@ export function buildSemanticTokens(source: ThemeSource): SemanticTokenSet { "surface.island.bg": source.bg.primary, "surface.island.fg": source.fg.primary, "surface.island.border": source.border, - "surface.island.inactive.veil": o(source.base === "dark" ? 0.2 : 0.04), + // Alpha scaled at runtime by the --inactive-panel-dim preference (default + // multiplier 1 → each theme's tuned alpha; see use-inactive-panel-dim-effect). + "surface.island.inactive.veil": overlayScaled( + source.base, + source.base === "dark" ? 0.2 : 0.04, + "--inactive-panel-dim", + 1, + ), "surface.floating.bg": source.bg.floating, "surface.floating.fg": source.fg.primary, "surface.floating.border": source.border, diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 275a362b..bdd1c999 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -139,11 +139,13 @@ "theme": "Theme", "iconTheme": "Icon Theme", "windowOpacity": "Window opacity", + "inactivePanelDim": "Inactive panel dimming", "reset": { "language": "Reset Language", "theme": "Reset Theme", "iconTheme": "Reset Icon Theme", - "windowOpacity": "Reset Window opacity" + "windowOpacity": "Reset Window opacity", + "inactivePanelDim": "Reset Inactive panel dimming" } }, "editor": { diff --git a/src/shared/i18n/locales/ko/settings.json b/src/shared/i18n/locales/ko/settings.json index 6d2f2991..ac3a84b1 100644 --- a/src/shared/i18n/locales/ko/settings.json +++ b/src/shared/i18n/locales/ko/settings.json @@ -139,11 +139,13 @@ "theme": "테마", "iconTheme": "아이콘 테마", "windowOpacity": "창 불투명도", + "inactivePanelDim": "비활성 패널 흐림 정도", "reset": { "language": "언어 초기화", "theme": "테마 초기화", "iconTheme": "아이콘 테마 초기화", - "windowOpacity": "창 불투명도 초기화" + "windowOpacity": "창 불투명도 초기화", + "inactivePanelDim": "비활성 패널 흐림 정도 초기화" } }, "editor": { diff --git a/src/shared/types/app-state.ts b/src/shared/types/app-state.ts index f6c042eb..a1459c7a 100644 --- a/src/shared/types/app-state.ts +++ b/src/shared/types/app-state.ts @@ -54,6 +54,11 @@ export const AppStateSchema = z.object({ // Changing this requires an app restart — `transparent` is constructor-only in Electron. windowOpacity: z.number().min(0).max(1).optional(), + // Inactive-panel dim multiplier — scales each theme's tuned inactive-veil + // alpha. 1 = theme default (omitted from storage), 0 = no dim, 2 = double. + // Applied at runtime via --inactive-panel-dim CSS var; no restart needed. + inactivePanelDim: z.number().min(0).max(2).optional(), + // UI density — 닫힌 집합; 부재=토큰 fallback ('default'). density: z.enum(["default", "compact"]).optional(), diff --git a/tests/unit/renderer/components/files/file-tree-row-4state.test.tsx b/tests/unit/renderer/components/files/file-tree-row-4state.test.tsx index b3a5257c..f0e01549 100644 --- a/tests/unit/renderer/components/files/file-tree-row-4state.test.tsx +++ b/tests/unit/renderer/components/files/file-tree-row-4state.test.tsx @@ -36,7 +36,6 @@ function renderRow(props: Partial> = {} isExpanded: false, isSelected: false, isFocused: false, - onToggle: () => {}, onClick: () => {}, ...props, }), diff --git a/tests/unit/renderer/components/files/file-tree-row-dir-click.test.tsx b/tests/unit/renderer/components/files/file-tree-row-dir-click.test.tsx new file mode 100644 index 00000000..01a0b07a --- /dev/null +++ b/tests/unit/renderer/components/files/file-tree-row-dir-click.test.tsx @@ -0,0 +1,166 @@ +/** + * Regression guard — FileTreeRow forwards the click event for FOLDERS too. + * + * The bug: row.tsx bound `onClick={isDir ? onToggle : (e) => onClick(e)}`. + * Folders got `onToggle` (called with NO event), so the parent's + * handleRowClick never saw shift/cmd modifiers and folder multi-selection + * (range / toggle) silently no-op'd. Files worked because they forwarded `e`. + * + * The fix makes the binding type-agnostic: `onClick={(e) => onClick(e)}`. + * Both files AND folders forward the event; the parent decides the primary + * action (plain folder click → expand, modified click → extend/toggle). + * + * HOW THIS TEST RENDERS WITHOUT A DOM + * ----------------------------------- + * DOM mounting is intentionally avoided in this project (happy-dom caused + * hangs — see portal-fiber-identity.test.ts). Mirroring browser-view.test.tsx, + * we mock `react` so its hooks forward to React's live dispatcher slot + * (`__CLIENT_INTERNALS…H`). Outside our render that slot is React's real + * dispatcher, so the mock is transparent and never leaks into other test + * files. During our render we point the slot at a minimal slot-indexed + * dispatcher, invoke the FileTreeRow function directly, and inspect the + * returned