Compare commits

...
Author SHA1 Message Date
bincxz 118c015bd5 refactor(scripts): remove stacked library view 2026-10-18 00:19:11 +08:00
13 changed files with 1 additions and 539 deletions

No files matched your search

-2
View File
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.sidePanel.viewList': 'List view',
'scripts.sidePanel.viewStacked': 'Stacked view',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
-2
View File
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
'scripts.sidePanel.library': 'Biblioteca',
'scripts.sidePanel.running': 'En ejecución',
'scripts.sidePanel.newScript': 'Nuevo script',
'scripts.sidePanel.viewList': 'Vista de lista',
'scripts.sidePanel.viewStacked': 'Vista apilada',
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
'scripts.running.unnamed': 'Script sin título',
'scripts.running.status.running': 'En ejecución',
-2
View File
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
'scripts.sidePanel.library': 'Библиотека',
'scripts.sidePanel.running': 'Выполняются',
'scripts.sidePanel.newScript': 'Новый скрипт',
'scripts.sidePanel.viewList': 'Список',
'scripts.sidePanel.viewStacked': 'Плитка',
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
'scripts.running.unnamed': 'Скрипт без названия',
'scripts.running.status.running': 'Выполняется',
@@ -1,16 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
test('scripts side panel view-toggle copy exists in every locale', () => {
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
}
});
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.sidePanel.viewList': '列表视图',
'scripts.sidePanel.viewStacked': '堆叠视图',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新增指令碼',
'scripts.sidePanel.viewList': '列表檢視',
'scripts.sidePanel.viewStacked': '堆疊檢視',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
@@ -1,27 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
parseScriptsViewMode,
SCRIPTS_VIEW_MODE_STORAGE_KEY,
} from "./useScriptsViewMode.ts";
test("scripts view mode parser only accepts 'stacked'", () => {
assert.equal(parseScriptsViewMode("stacked"), "stacked");
assert.equal(parseScriptsViewMode("list"), "list");
assert.equal(parseScriptsViewMode("grid"), "list");
assert.equal(parseScriptsViewMode(null), "list");
assert.equal(parseScriptsViewMode(""), "list");
});
test("scripts view mode hook persists the chosen mode", () => {
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
assert.match(source, /useStoredString/);
// The shared hook owns persistence and same-/cross-window subscriptions.
assert.match(
source,
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
);
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
});
-19
View File
@@ -1,19 +0,0 @@
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
import { useStoredString } from "./useStoredString";
/** View modes for the scripts side panel library:
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
* wrap-around chips). */
export type ScriptsViewMode = "list" | "stacked";
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
/** Pure resolver so callers and tests share one validation rule. */
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
value === "stacked" ? "stacked" : "list";
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
value === "list" || value === "stacked";
export const useScriptsViewMode = () =>
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
@@ -1,59 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
test("script view changes follow native storage events from another window", async (t) => {
const windowA = new EventTarget();
const windowB = new EventTarget();
let activeWindow = windowA;
const data = new Map<string, string>();
const values = {
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
let a!: ReturnType<typeof useScriptsViewMode>;
let b!: ReturnType<typeof useScriptsViewMode>;
function FirstWindow() { a = useScriptsViewMode(); return null; }
function SecondWindow() { b = useScriptsViewMode(); return null; }
let first!: ReactTestRenderer;
let second!: ReactTestRenderer;
t.after(async () => {
activeWindow = windowA; await act(async () => first?.unmount());
activeWindow = windowB; await act(async () => second?.unmount());
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
assert.equal(a[0], "list"); assert.equal(b[0], "list");
// BrowserWindow A writes shared storage. Electron emits the native event
// in B, not an adapter CustomEvent from A's independent JS environment.
activeWindow = windowA; await act(async () => a[1]("stacked"));
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
assert.equal(b[0], "list");
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
const event = new Event("storage");
Object.defineProperty(event, "key", { value: key });
await act(async () => { target.dispatchEvent(event); });
};
activeWindow = windowB; await notify(windowB);
assert.equal(b[0], "stacked");
await act(async () => b[1]("list"));
assert.equal(a[0], "stacked");
activeWindow = windowA; await notify(windowA);
assert.equal(a[0], "list");
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
await notify(windowA, "unrelated");
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
});
@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
import type { Snippet } from "../types";
test("stacked library continues loading after running a script and returning", async (t) => {
const env = installDomEnvironment();
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
class Observer {
target: Element | null = null;
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
observe(target: Element) { this.target = target; }
disconnect() { this.target = null; }
intersect = () => {
if (this.target?.isConnected) {
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
};
}
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
env.cleanup();
});
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
}));
const executed: string[] = [];
await renderer.render(
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
);
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
const chipCount = () => library()?.querySelectorAll("button").length;
const button = (label: string) => {
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
assert.ok(result, `missing button ${label}`);
return result;
};
assert.equal(chipCount(), 120);
const firstSentinel = observers.at(-1)?.target;
assert.ok(firstSentinel?.isConnected);
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(executed, ["script-0"]);
assert.equal(library(), null, "execution opens Running");
assert.equal(firstSentinel.isConnected, false);
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 120);
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
assert.notEqual(observers.at(-1)?.target, firstSentinel);
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 240);
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 240, "returning preserves the loaded window");
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 300);
assert.ok(button("Script 299"));
});
-28
View File
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
assert.match(source, /isNonPrimaryPointer/);
assert.match(source, /primaryOnlyDragHandlers/);
});
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
assert.match(source, /useScriptsViewMode/);
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
assert.ok(toggle, "view toggle button should swap list / stacked icons");
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
// The chosen mode persists via the shared hook so it survives app restarts.
assert.match(source, /useScriptsViewMode\(\)/);
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
});
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
assert.ok(stacked, "stacked branch should exist in the content area");
assert.match(stacked[0], /flex flex-wrap/);
// Chips reuse the same click handler (multi-select / run / execute) as rows.
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
// Packages stay visible as section headers with their counts.
assert.match(stacked[0], /item\.row\.name/);
assert.match(stacked[0], /item\.countLabel/);
assert.match(source, /const SnippetChip = memo/);
// The stacked chip keeps the same context menu contract (edit / delete / run).
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
assert.ok(chip, "SnippetChip component should exist");
assert.match(chip[0], /ContextMenuContent/);
assert.match(chip[0], /onRunParallel/);
assert.match(chip[0], /onEdit/);
});
+1 -305
View File
@@ -15,8 +15,6 @@ import {
Expand,
FolderPlus,
Layers,
LayoutGrid,
List,
Minimize2,
Package,
Play,
@@ -29,7 +27,6 @@ import {
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
collectSnippetPackageTreePaths,
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
// so cap the initial render and grow lazily as the user scrolls to keep a huge
// library (or a broad search) from mounting every chip at once.
const STACKED_INITIAL_RENDER_COUNT = 120;
const STACKED_RENDER_BATCH = 120;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
return body.length > 0 && !body.includes('/');
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
snippetsBeforeChildren = false,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
/** Emit each package's own snippets before its sub-packages so flat views
* (stacked chips) keep every package's chips attached to its section. */
snippetsBeforeChildren?: boolean;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
});
if (!isExpanded) return;
if (snippetsBeforeChildren) {
// Flat stacked view: a package's chips must follow its own section
// header, not trail after its nested children's chips.
emitSnippets(localSnippets, pkg, depth);
children.forEach((child) => walk(child, depth + 1));
return;
}
children.forEach((child) => walk(child, depth + 1));
emitSnippets(localSnippets, pkg, depth);
};
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [viewMode, setViewMode] = useScriptsViewMode();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [renamingPackagePath, setRenamingPackagePath] = useState('');
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
const packageDialogRef = useRef<HTMLDivElement>(null);
const packageNameInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
expandedPaths,
// Stacked chips wrap flat, so each package's chips must stay attached
// to its own section header.
snippetsBeforeChildren: viewMode === 'stacked',
});
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [rows, searchMatches, t, isVisible]);
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
// Restart the lazy window whenever the underlying result set changes
// (new search, package list changes, snippet edits, view-mode switch) — but
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
// same result set; resetting there would drop the user's lazy-loaded window
// and remount the package they just toggled, forcing them to re-scroll.
// Keyed by data identity, not by listItems reference, for that reason.
// Reset during render — not in an effect — so the first render of a new
// result set never slices with the previous, potentially very large window.
const stackedResultSetKey = useMemo(() => {
if (searchMatches !== null) {
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
}
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
.map((snippet) => snippet.id)
.join('\u0000')}`;
}, [searchMatches, viewMode, packages, snippets]);
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
if (prevResultSetKey !== stackedResultSetKey) {
setPrevResultSetKey(stackedResultSetKey);
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
}
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
useEffect(() => {
if (subView !== 'library' || !hasMoreStackedItems) return;
const sentinel = stackedSentinelRef.current;
if (!sentinel) return;
// Re-created per batch: observing always emits an initial intersection
// callback, so short result sets that never scroll keep loading.
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
)}
disabled={!hasAnyContent}
aria-label={viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
aria-pressed={viewMode === 'stacked'}
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
>
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : viewMode === 'stacked' ? (
<div
className="h-full overflow-auto px-2 py-1.5"
data-scripts-view="stacked"
>
<div className="flex flex-wrap items-center content-start gap-1.5">
{listItems.slice(0, stackedRenderLimit).map((item) => {
if (item.kind === 'package') {
// Mirror the list view's PackageRow context menu so stacked
// mode keeps rename/delete reachable without switching views.
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
const header = (
<button
key={item.key}
type="button"
onClick={() => togglePackage(item.row.path)}
aria-expanded={item.row.isExpanded}
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
>
<ChevronRight
size={11}
className={cn(
'shrink-0 text-muted-foreground transition-transform',
item.row.isExpanded && 'rotate-90',
!item.row.hasChildren && 'opacity-0',
)}
/>
<Package size={11} className="shrink-0 text-primary/80" />
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
{item.row.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{item.countLabel}
</span>
</button>
);
if (!canMutatePackages) {
return header;
}
return (
<ContextMenu key={item.key}>
<ContextMenuTrigger asChild>
{header}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => openRenamePackageDialog(item.row.path)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => requestDeletePackage(item.row.path)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
const isScript = isScriptSnippet(snippet);
// Search results drop the package headers, so surface each
// snippet's package on the chip itself to keep results
// distinguishable (labels are not unique across packages).
const chipSubtitle = item.kind === 'search'
? (snippet.package || t('terminal.toolbar.library'))
: undefined;
return (
<SnippetChip
key={item.key}
snippet={snippet}
subtitle={chipSubtitle}
selected={selectedSnippetIds.has(snippet.id)}
multiSelect={isMultiSelectMode}
onClick={() => handleSnippetClick(snippet)}
onEdit={() => handleEditSnippet(snippet)}
onDelete={() => handleDeleteSnippet(snippet.id)}
onCopyCommand={() => handleCopySnippetCommand(snippet)}
copyCommandLabel={t('scripts.actions.copyCommand')}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'parallel')
: undefined}
onRunSequential={isScript && onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'sequential')
: undefined}
runParallelLabel={isScript
? t('scripts.actions.runParallel')
: t('scripts.actions.runOnAllTabs')}
runSequentialLabel={t('scripts.actions.runSequential')}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
})}
</div>
{hasMoreStackedItems ? (
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
) : null}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
));
SnippetRow.displayName = 'SnippetRow';
interface SnippetChipProps {
snippet: Snippet;
/** Optional package context shown on the chip (used by stacked search
* results, where the package headers are dropped). */
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
onRunParallel?: () => void;
onRunSequential?: () => void;
onCopyCommand?: () => void;
copyCommandLabel?: string;
runParallelLabel?: string;
runSequentialLabel?: string;
editLabel: string;
deleteLabel: string;
}
/**
* Compact chip for the stacked (wrap) view. Shares the same click → run /
* execute and right-click context menu as the list rows; only the layout is
* denser. Drag reordering stays list-view-only.
*/
const SnippetChip = memo<SnippetChipProps>(({
snippet,
subtitle,
selected = false,
multiSelect = false,
onClick,
onEdit,
onDelete,
onRunParallel,
onRunSequential,
onCopyCommand,
copyCommandLabel,
runParallelLabel,
runSequentialLabel,
editLabel,
deleteLabel,
}) => (
<ContextMenu>
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
<ContextMenuTrigger asChild>
<div className="min-w-0 max-w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
)}
>
{multiSelect ? (
<CheckSquare
size={11}
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
/>
) : isScriptSnippet(snippet) ? (
<Play size={11} className="shrink-0 text-primary" />
) : (
<Zap size={11} className="shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate">{snippet.label}</span>
{subtitle ? (
<>
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
{subtitle}
</span>
</>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" align="start">
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onRunParallel ? (
<ContextMenuItem onClick={onRunParallel}>
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
</ContextMenuItem>
) : null}
{onRunSequential ? (
<ContextMenuItem onClick={onRunSequential}>
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
{onCopyCommand ? (
<ContextMenuItem onClick={onCopyCommand}>
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
));
SnippetChip.displayName = 'SnippetChip';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
-2
View File
@@ -296,8 +296,6 @@ export const STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB = 'netcatty_local_
export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_focus_sidebar_width';
export const STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH = 'netcatty_terminal_host_tree_width_v1';
export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_v1';
/** Terminal scripts side panel library: 'list' vs 'stacked' view mode. */
export const STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW = 'netcatty:scripts:sidePanelView';
export const STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN = 'netcatty_terminal_compose_bar_open_v1';
/** Opt-in "broadcast without password protection" switch (terminal compose bar, issue #3488). */
export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1';