Skip to content
Open
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
5 changes: 5 additions & 0 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions frontend/app/tab/tab.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +95 to +114
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hover state will override the locked tab styling.

The locked tab background and border styling will be overridden when the user hovers over a locked tab because body:not(.nohover) .tab:hover .tab-inner (specificity 0,4,1) has higher specificity than .tab.locked .tab-inner (specificity 0,3,0). This means hovering over a locked tab will hide its amber visual indicator, degrading the UX.

🎨 Proposed fix to preserve locked styling on hover
     &.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;
         }
     }
+
+    &.locked:hover .tab-inner,
+    &.locked.dragging .tab-inner {
+        box-shadow: inset 0 0 0 1px rgb(255 180 0 / 0.45);
+        background: rgb(255 180 0 / 0.08);
+    }
 }

Alternatively, wrap the hover-specific overrides with :not(.locked):

body:not(.nohover) .tab:hover:not(.locked) {
    .tab-inner {
        border-color: transparent;
        background: rgb(from var(--main-text-color) r g b / 0.1);
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
&.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;
}
}
&.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;
}
}
&.locked:hover .tab-inner,
&.locked.dragging .tab-inner {
box-shadow: inset 0 0 0 1px rgb(255 180 0 / 0.45);
background: rgb(255 180 0 / 0.08);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/tab/tab.scss` around lines 95 - 114, The hover rule
body:not(.nohover) .tab:hover .tab-inner overrides the locked styling in
.tab.locked .tab-inner; modify the hover rule so it does not apply to locked
tabs (e.g., add :not(.locked) to the hover selector) so that .tab.locked
.tab-inner keeps its amber border/background on hover; update the selector used
for hover overrides (body:not(.nohover) .tab:hover .tab-inner) to exclude
.locked tabs (or increase specificity to prefer .tab.locked .tab-inner) and
verify .lock-icon and .close behaviors remain unchanged.

}

// 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.
Expand Down
33 changes: 25 additions & 8 deletions frontend/app/tab/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -42,6 +43,7 @@ interface TabVProps {
isNew: boolean;
badges?: Badge[] | null;
flagColor?: string | null;
locked?: boolean;
onClick: () => void;
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
Expand All @@ -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<HTMLDivElement, TabVProps>((props, ref) => {
const {
tabId,
Expand All @@ -62,6 +65,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
isNew,
badges,
flagColor,
locked,
onClick,
onClose,
onDragStart,
Expand Down Expand Up @@ -185,6 +189,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
active,
dragging: isDragging,
"new-tab": isNew,
locked,
})}
onMouseDown={onDragStart}
onClick={onClick}
Expand All @@ -205,14 +210,22 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
{displayName}
</div>
<TabBadges badges={badges} flagColor={flagColor} />
<Button
className="ghost grey close"
onClick={onClose}
onMouseDown={handleMouseDownOnClose}
title="Close Tab"
>
<i className="fa fa-solid fa-xmark" />
</Button>
{locked ? (
<i
className="lock-icon fa fa-solid fa-lock"
style={{ color: TabLockedColor }}
title="Tab is locked"
/>
) : (
<Button
className="ghost grey close"
onClick={onClose}
onMouseDown={handleMouseDownOnClose}
title="Close Tab"
>
<i className="fa fa-solid fa-xmark" />
</Button>
)}
</div>
</div>
);
Expand All @@ -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<HTMLDivElement, TabProps>((props, ref) => {
const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
const env = useWaveEnv<TabEnv>();
Expand All @@ -250,6 +264,8 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
}
}

const locked = !!tabData?.meta?.["tab:locked"];

const loadedRef = useRef(false);
const renameRef = useRef<(() => void) | null>(null);
const tabModel = getTabModelByTabId(id, env);
Expand Down Expand Up @@ -304,6 +320,7 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
isNew={isNew}
badges={badges}
flagColor={flagColor}
locked={locked}
onClick={handleTabClick}
onClose={onClose}
onDragStart={onDragStart}
Expand Down
5 changes: 5 additions & 0 deletions frontend/app/tab/tabbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation();
if (isTabLocked(tabId)) {
return;
}
env.electron
.closeTab(workspace.oid, tabId, confirmClose)
.then((didClose) => {
Expand Down
17 changes: 15 additions & 2 deletions frontend/app/tab/tabcontextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
21 changes: 21 additions & 0 deletions frontend/app/tab/tablock.ts
Original file line number Diff line number Diff line change
@@ -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"));
}
22 changes: 21 additions & 1 deletion frontend/app/tab/vtab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
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(
<VTab
tab={tab}
active={false}
isDragging={false}
isReordering={false}
onSelect={() => null}
onClose={withClose ? () => null : undefined}
onDragStart={() => null}
onDragOver={() => null}
onDrop={() => null}
Expand Down Expand Up @@ -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");
});
});
20 changes: 18 additions & 2 deletions frontend/app/tab/vtab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,6 +16,7 @@ export interface VTabItem {
badge?: Badge | null;
badges?: Badge[] | null;
flagColor?: string | null;
locked?: boolean;
}

interface VTabProps {
Expand All @@ -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,
Expand All @@ -57,6 +60,7 @@ export function VTab({
const editableRef = useRef<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const badges = tab.badges ?? (tab.badge ? [tab.badge] : null);
const locked = !!tab.locked;

const rawFlagColor = tab.flagColor;
let flagColor: string | null = null;
Expand Down Expand Up @@ -174,6 +178,9 @@ export function VTab({
{!active && !isReordering && (
<div className="pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-transparent transition-colors group-hover:bg-foreground/10" />
)}
{locked && (
<div className="pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-[#FFB400]/10 ring-1 ring-inset ring-[#FFB400]/40" />
)}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-[5%] right-[5%] h-px bg-border/70",
Expand All @@ -189,7 +196,8 @@ export function VTab({
ref={editableRef}
className={cn(
"min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right] pr-3",
onClose && !isReordering && "group-hover:pr-6",
locked && "pr-6",
!locked && onClose && !isReordering && "group-hover:pr-6",
isEditable && "rounded-[2px] bg-white/15 outline-none"
)}
contentEditable={isEditable}
Expand All @@ -202,7 +210,15 @@ export function VTab({
>
{tab.name}
</div>
{onClose && (
{locked && (
<i
className="fa fa-solid fa-lock absolute top-1/2 right-0 shrink-0 -translate-y-1/2 py-1 pl-1 pr-3 text-[10px]"
style={{ color: TabLockedColor }}
title="Tab is locked"
aria-label="Tab is locked"
/>
)}
{!locked && onClose && (
<button
type="button"
className={cn(
Expand Down
11 changes: 10 additions & 1 deletion frontend/app/tab/vtabbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cn, fireAndForget } from "@/util/util";
import { useAtomValue } from "jotai";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { buildTabBarContextMenu, buildTabContextMenu } from "./tabcontextmenu";
import { isTabLocked } from "./tablock";
import { UpdateStatusBanner } from "./updatebanner";
import { VTab, VTabItem } from "./vtab";
import { VTabBarEnv } from "./vtabbarenv";
Expand Down Expand Up @@ -102,6 +103,7 @@ interface VTabWrapperProps {
onHoverChanged: (isHovered: boolean) => void;
}

/** Connects a left-bar tab to its wave object, deriving name, badges, flag color, and locked state from tab meta. */
function VTabWrapper({
tabId,
active,
Expand Down Expand Up @@ -150,6 +152,7 @@ function VTabWrapper({
name: tabData?.name ?? "",
badges,
flagColor,
locked: !!tabData?.meta?.["tab:locked"],
};

const handleContextMenu = useCallback(
Expand Down Expand Up @@ -184,6 +187,7 @@ function VTabWrapper({
);
}

/** The vertical (left) tab bar: renders the workspace's tabs as a reorderable list and routes close requests, honoring tab locks. */
export function VTabBar({ workspace, className }: VTabBarProps) {
const env = useWaveEnv<VTabBarEnv>();
const activeTabId = useAtomValue(env.atoms.staticTabId);
Expand Down Expand Up @@ -374,7 +378,12 @@ export function VTabBar({ workspace, className }: VTabBarProps) {
hoverResetVersion={hoverResetVersion}
index={index}
onSelect={() => env.electron.setActiveTab(tabId)}
onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))}
onClose={() => {
if (isTabLocked(tabId)) {
return;
}
fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false));
}}
onRename={(newName) =>
fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName))
}
Expand Down
Loading