diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..d326cf2336 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -21,6 +21,7 @@ import { WOS, } from "@/app/store/global"; import { getActiveTabModel } from "@/app/store/tab-model"; +import { isTabLocked } from "@/app/tab/tablock"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; @@ -128,9 +129,13 @@ function getStaticTabBlockCount(): number { return tabData?.blockids?.length ?? 0; } +/** Closes the active static tab via the keyboard shortcut, unless the tab is locked. */ function simpleCloseStaticTab() { const workspaceId = globalStore.get(atoms.workspaceId); const tabId = globalStore.get(atoms.staticTabId); + if (isTabLocked(tabId)) { + return; + } const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; getApi() .closeTab(workspaceId, tabId, confirmClose) diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index ad10fc814e..4a01dc88db 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -91,6 +91,27 @@ .close { visibility: hidden; } + + &.locked { + .tab-inner { + box-shadow: inset 0 0 0 1px rgb(255 180 0 / 0.45); + background: rgb(255 180 0 / 0.08); + } + + .close { + display: none; + } + + .lock-icon { + position: absolute; + top: 50%; + right: 6px; + transform: translate3d(0, -50%, 0); + z-index: var(--zindex-tab-name); + font-size: 11px; + pointer-events: none; + } + } } // Only apply hover effects when not in nohover mode. This prevents the previously-hovered tab from remaining hovered while a tab view is not mounted. diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 4972a13daa..e42556c09e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -16,6 +16,7 @@ import { makeORef } from "../store/wos"; import { TabBadges } from "./tabbadges"; import "./tab.scss"; import { buildTabContextMenu } from "./tabcontextmenu"; +import { TabLockedColor } from "./tablock"; export type TabEnv = WaveEnvSubset<{ rpc: { @@ -42,6 +43,7 @@ interface TabVProps { isNew: boolean; badges?: Badge[] | null; flagColor?: string | null; + locked?: boolean; onClick: () => void; onClose: (event: React.MouseEvent | null) => void; onDragStart: (event: React.MouseEvent) => void; @@ -51,6 +53,7 @@ interface TabVProps { renameRef?: React.RefObject<(() => void) | null>; } +/** Presentational top-bar tab: renders the name, badges, and either a close button or, when locked, a lock icon. */ const TabV = forwardRef((props, ref) => { const { tabId, @@ -62,6 +65,7 @@ const TabV = forwardRef((props, ref) => { isNew, badges, flagColor, + locked, onClick, onClose, onDragStart, @@ -185,6 +189,7 @@ const TabV = forwardRef((props, ref) => { active, dragging: isDragging, "new-tab": isNew, + locked, })} onMouseDown={onDragStart} onClick={onClick} @@ -205,14 +210,22 @@ const TabV = forwardRef((props, ref) => { {displayName} - + {locked ? ( + + ) : ( + + )} ); @@ -233,6 +246,7 @@ interface TabProps { onLoaded: () => void; } +/** Connects a top-bar tab to its wave object, deriving name, badges, flag color, and locked state from tab meta. */ const TabInner = forwardRef((props, ref) => { const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props; const env = useWaveEnv(); @@ -250,6 +264,8 @@ const TabInner = forwardRef((props, ref) => { } } + const locked = !!tabData?.meta?.["tab:locked"]; + const loadedRef = useRef(false); const renameRef = useRef<(() => void) | null>(null); const tabModel = getTabModelByTabId(id, env); @@ -304,6 +320,7 @@ const TabInner = forwardRef((props, ref) => { isNew={isNew} badges={badges} flagColor={flagColor} + locked={locked} onClick={handleTabClick} onClose={onClose} onDragStart={onDragStart} diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index b404afcb7e..d16d21af54 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -13,6 +13,7 @@ import { OverlayScrollbars } from "overlayscrollbars"; import { createRef, memo, useCallback, useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; import { Tab } from "./tab"; +import { isTabLocked } from "./tablock"; import "./tabbar.scss"; import { TabBarEnv } from "./tabbarenv"; import { UpdateStatusBanner } from "./updatebanner"; @@ -539,8 +540,12 @@ const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { setNewTabIdDebounced(null); }; + /** Closes a tab from the top tab bar, ignoring the request when the tab is locked. */ const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); + if (isTabLocked(tabId)) { + return; + } env.electron .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { diff --git a/frontend/app/tab/tabcontextmenu.ts b/frontend/app/tab/tabcontextmenu.ts index bc87302d4c..c1064e9e8f 100644 --- a/frontend/app/tab/tabcontextmenu.ts +++ b/frontend/app/tab/tabcontextmenu.ts @@ -73,7 +73,20 @@ export function buildTabContextMenu( ), })), ]; - menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" }); + menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }); + const isLocked = !!globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:locked")); + menu.push( + { + label: isLocked ? "Unlock Tab" : "Lock Tab", + type: "checkbox", + checked: isLocked, + click: () => + fireAndForget(() => + env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:locked": !isLocked } }) + ), + }, + { type: "separator" } + ); const fullConfig = globalStore.get(env.atoms.fullConfigAtom); const backgrounds = fullConfig?.backgrounds ?? {}; const bgKeys = Object.keys(backgrounds).filter((k) => backgrounds[k] != null); @@ -115,6 +128,6 @@ export function buildTabContextMenu( menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); } menu.push(...buildTabBarContextMenu(env), { type: "separator" }); - menu.push({ label: "Close Tab", click: () => onClose(null) }); + menu.push({ label: "Close Tab", enabled: !isLocked, click: () => onClose(null) }); return menu; } diff --git a/frontend/app/tab/tablock.ts b/frontend/app/tab/tablock.ts new file mode 100644 index 0000000000..79d1ab35ea --- /dev/null +++ b/frontend/app/tab/tablock.ts @@ -0,0 +1,21 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getTabMetaKeyAtom, globalStore } from "@/app/store/global"; + +// Amber padlock tint shown on locked tabs. +export const TabLockedColor = "#FFB400"; + +/** + * Returns whether a tab is currently locked (close-protected). + * + * Reads the `tab:locked` meta synchronously from the global store so it can be + * called from non-reactive close handlers (button, context menu, keybinding) + * to short-circuit a close before it reaches the backend. + */ +export function isTabLocked(tabId: string): boolean { + if (!tabId) { + return false; + } + return !!globalStore.get(getTabMetaKeyAtom(tabId, "tab:locked")); +} diff --git a/frontend/app/tab/vtab.test.tsx b/frontend/app/tab/vtab.test.tsx index b995b6a72a..1c1d09acde 100644 --- a/frontend/app/tab/vtab.test.tsx +++ b/frontend/app/tab/vtab.test.tsx @@ -4,11 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { VTab, VTabItem } from "./vtab"; +import { TabLockedColor } from "./tablock"; const OriginalCss = globalThis.CSS; const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i; -function renderVTab(tab: VTabItem): string { +/** Renders a VTab to static markup for assertions; pass withClose to supply an onClose handler. */ +function renderVTab(tab: VTabItem, withClose = false): string { return renderToStaticMarkup( null} + onClose={withClose ? () => null : undefined} onDragStart={() => null} onDragOver={() => null} onDrop={() => null} @@ -61,3 +64,20 @@ describe("VTab badges", () => { expect(markup).toContain("#4ade80"); }); }); + +describe("VTab lock", () => { + it("shows a lock icon and hides the close button when locked", () => { + const markup = renderVTab({ id: "tab-3", name: "Prod", locked: true }, true); + + expect(markup).toContain("fa-lock"); + expect(markup).toContain(TabLockedColor); + expect(markup).not.toContain("fa-xmark"); + }); + + it("shows the close button and no lock icon when unlocked", () => { + const markup = renderVTab({ id: "tab-4", name: "Scratch" }, true); + + expect(markup).toContain("fa-xmark"); + expect(markup).not.toContain("fa-lock"); + }); +}); diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx index 4c70d5ec37..d81ce68c72 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -6,6 +6,7 @@ import { validateCssColor } from "@/util/color-validator"; import { cn } from "@/util/util"; import { useCallback, useEffect, useRef, useState } from "react"; import { TabBadges } from "./tabbadges"; +import { TabLockedColor } from "./tablock"; const RenameFocusDelayMs = 50; @@ -15,6 +16,7 @@ export interface VTabItem { badge?: Badge | null; badges?: Badge[] | null; flagColor?: string | null; + locked?: boolean; } interface VTabProps { @@ -35,6 +37,7 @@ interface VTabProps { renameRef?: React.RefObject<(() => void) | null>; } +/** Presentational left-bar tab: renders the name and badges, and either a close button or, when locked, a lock icon. */ export function VTab({ tab, active, @@ -57,6 +60,7 @@ export function VTab({ const editableRef = useRef(null); const editableTimeoutRef = useRef(null); const badges = tab.badges ?? (tab.badge ? [tab.badge] : null); + const locked = !!tab.locked; const rawFlagColor = tab.flagColor; let flagColor: string | null = null; @@ -174,6 +178,9 @@ export function VTab({ {!active && !isReordering && (
)} + {locked && ( +
+ )}
{tab.name}
- {onClose && ( + {locked && ( + + )} + {!locked && onClose && (