Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
31 changes: 23 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 @@ -62,6 +64,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
isNew,
badges,
flagColor,
locked,
onClick,
onClose,
onDragStart,
Expand Down Expand Up @@ -185,6 +188,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
active,
dragging: isDragging,
"new-tab": isNew,
locked,
})}
onMouseDown={onDragStart}
onClick={onClick}
Expand All @@ -205,14 +209,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 Down Expand Up @@ -250,6 +262,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 +318,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");
});
});
19 changes: 17 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 Down Expand Up @@ -57,6 +59,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 +177,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 +195,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 +209,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
9 changes: 8 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 @@ -150,6 +151,7 @@ function VTabWrapper({
name: tabData?.name ?? "",
badges,
flagColor,
locked: !!tabData?.meta?.["tab:locked"],
};

const handleContextMenu = useCallback(
Expand Down Expand Up @@ -374,7 +376,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