Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 10 additions & 2 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -109,6 +113,7 @@ export function App() {
iconThemePreference,
themePreference,
opacity,
inactivePanelDim,
editorFontSize,
editorFontFamily,
editorFontLigatures,
Expand All @@ -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 ||
Expand All @@ -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,
},
{
Expand Down Expand Up @@ -195,6 +201,7 @@ export function App() {
iconThemePreference,
themePreference,
opacity,
inactivePanelDim,
editorFontSize,
editorFontFamily,
editorFontLigatures,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -186,6 +187,9 @@ export async function bootstrapAppState(): Promise<void> {
// 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({
Expand Down
13 changes: 9 additions & 4 deletions src/renderer/components/files/file-tree/row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -91,7 +97,6 @@ export function FileTreeRow({
decoration,
isIgnored = false,
isCut = false,
onToggle,
onClick,
onDoubleClick,
onContextMenu,
Expand Down Expand Up @@ -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}
Expand Down
1 change: 0 additions & 1 deletion src/renderer/components/files/file-tree/virtual-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
59 changes: 59 additions & 0 deletions src/renderer/components/settings/panels/appearance-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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<number>(opacity);
const [localDim, setLocalDim] = useState<number>(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");

Expand Down Expand Up @@ -215,6 +234,46 @@ export function AppearancePanel() {
</span>
</div>
</SettingsSection>

{/* 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). */}
<SettingsSection
label={t("appearance.inactivePanelDim")}
dirty={dimDirty}
onReset={() => setDim(INACTIVE_PANEL_DIM_DEFAULT)}
>
<div className="flex items-center gap-3">
<Slider.Root
min={INACTIVE_PANEL_DIM_MIN}
max={INACTIVE_PANEL_DIM_MAX}
step={DIM_STEP}
value={[localDim]}
onValueChange={(vals) => {
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"
>
<Slider.Track className="relative h-1 w-full grow rounded-(--radius-control) bg-muted border border-border">
<Slider.Range className="absolute h-full rounded-(--radius-control) bg-[var(--state-selected-bg)]" />
</Slider.Track>
<Slider.Thumb
className={cn(
"block size-4 rounded-full border border-[var(--state-selected-bg)] bg-[var(--state-selected-bg)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"transition-colors",
)}
/>
</Slider.Root>
<span className="w-10 text-right text-app-ui-sm text-muted-foreground tabular-nums">
{dimPercent}%
</span>
</div>
</SettingsSection>
</div>
);
}
Expand Down
23 changes: 23 additions & 0 deletions src/renderer/hooks/use-inactive-panel-dim-effect.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
95 changes: 95 additions & 0 deletions src/renderer/state/stores/inactive-panel-dim.ts
Original file line number Diff line number Diff line change
@@ -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<InactivePanelDimState>((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}`);
});
},
};
});
Loading