mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118c015bd5 |
No files matched your search
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
|||||||
'scripts.sidePanel.library': 'Library',
|
'scripts.sidePanel.library': 'Library',
|
||||||
'scripts.sidePanel.running': 'Running',
|
'scripts.sidePanel.running': 'Running',
|
||||||
'scripts.sidePanel.newScript': 'New script',
|
'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.empty': 'No scripts are running in this session.',
|
||||||
'scripts.running.unnamed': 'Untitled script',
|
'scripts.running.unnamed': 'Untitled script',
|
||||||
'scripts.running.status.running': 'Running',
|
'scripts.running.status.running': 'Running',
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
|||||||
'scripts.sidePanel.library': 'Biblioteca',
|
'scripts.sidePanel.library': 'Biblioteca',
|
||||||
'scripts.sidePanel.running': 'En ejecución',
|
'scripts.sidePanel.running': 'En ejecución',
|
||||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
'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.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||||
'scripts.running.unnamed': 'Script sin título',
|
'scripts.running.unnamed': 'Script sin título',
|
||||||
'scripts.running.status.running': 'En ejecución',
|
'scripts.running.status.running': 'En ejecución',
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
|||||||
'scripts.sidePanel.library': 'Библиотека',
|
'scripts.sidePanel.library': 'Библиотека',
|
||||||
'scripts.sidePanel.running': 'Выполняются',
|
'scripts.sidePanel.running': 'Выполняются',
|
||||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||||
'scripts.sidePanel.viewList': 'Список',
|
|
||||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
|
||||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||||
'scripts.running.unnamed': 'Скрипт без названия',
|
'scripts.running.unnamed': 'Скрипт без названия',
|
||||||
'scripts.running.status.running': 'Выполняется',
|
'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.library': '脚本库',
|
||||||
'scripts.sidePanel.running': '运行中',
|
'scripts.sidePanel.running': '运行中',
|
||||||
'scripts.sidePanel.newScript': '新建脚本',
|
'scripts.sidePanel.newScript': '新建脚本',
|
||||||
'scripts.sidePanel.viewList': '列表视图',
|
|
||||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
|
||||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||||
'scripts.running.unnamed': '未命名脚本',
|
'scripts.running.unnamed': '未命名脚本',
|
||||||
'scripts.running.status.running': '运行中',
|
'scripts.running.status.running': '运行中',
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
|||||||
'scripts.sidePanel.library': '指令碼庫',
|
'scripts.sidePanel.library': '指令碼庫',
|
||||||
'scripts.sidePanel.running': '執行中',
|
'scripts.sidePanel.running': '執行中',
|
||||||
'scripts.sidePanel.newScript': '新增指令碼',
|
'scripts.sidePanel.newScript': '新增指令碼',
|
||||||
'scripts.sidePanel.viewList': '列表檢視',
|
|
||||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
|
||||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||||
'scripts.running.unnamed': '未命名指令碼',
|
'scripts.running.unnamed': '未命名指令碼',
|
||||||
'scripts.running.status.running': '執行中',
|
'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);
|
|
||||||
});
|
|
||||||
@@ -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"));
|
|
||||||
});
|
|
||||||
@@ -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, /isNonPrimaryPointer/);
|
||||||
assert.match(source, /primaryOnlyDragHandlers/);
|
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/);
|
|
||||||
});
|
|
||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
Expand,
|
Expand,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutGrid,
|
|
||||||
List,
|
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Package,
|
Package,
|
||||||
Play,
|
Play,
|
||||||
@@ -29,7 +27,6 @@ import {
|
|||||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
import { useI18n } from '../application/i18n/I18nProvider';
|
import { useI18n } from '../application/i18n/I18nProvider';
|
||||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
|
||||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||||
import {
|
import {
|
||||||
collectSnippetPackageTreePaths,
|
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';
|
'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;
|
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 isRootPackagePath = (path: string): boolean => {
|
||||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||||
return body.length > 0 && !body.includes('/');
|
return body.length > 0 && !body.includes('/');
|
||||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
|||||||
snippets,
|
snippets,
|
||||||
packages,
|
packages,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
snippetsBeforeChildren = false,
|
|
||||||
}: {
|
}: {
|
||||||
snippets: Snippet[];
|
snippets: Snippet[];
|
||||||
packages: string[];
|
packages: string[];
|
||||||
expandedPaths: Set<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[] {
|
}): TreeRow[] {
|
||||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||||
|
|
||||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isExpanded) return;
|
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));
|
children.forEach((child) => walk(child, depth + 1));
|
||||||
emitSnippets(localSnippets, pkg, depth);
|
emitSnippets(localSnippets, pkg, depth);
|
||||||
};
|
};
|
||||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||||
const [newPackageName, setNewPackageName] = useState('');
|
const [newPackageName, setNewPackageName] = useState('');
|
||||||
const [packageError, setPackageError] = useState('');
|
const [packageError, setPackageError] = useState('');
|
||||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
|
||||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
snippets,
|
snippets,
|
||||||
packages,
|
packages,
|
||||||
expandedPaths,
|
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 =
|
type ScriptsListItem =
|
||||||
| { key: string; kind: 'search'; snippet: Snippet }
|
| { key: string; kind: 'search'; snippet: Snippet }
|
||||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
});
|
});
|
||||||
}, [rows, searchMatches, t, isVisible]);
|
}, [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(
|
const handleSnippetClick = useCallback(
|
||||||
(snippet: Snippet) => {
|
(snippet: Snippet) => {
|
||||||
if (isMultiSelectMode) {
|
if (isMultiSelectMode) {
|
||||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
|||||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||||
{t('common.noResultsFound')}
|
{t('common.noResultsFound')}
|
||||||
</div>
|
</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
|
<FixedSizeVirtualList
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
|||||||
));
|
));
|
||||||
SnippetRow.displayName = 'SnippetRow';
|
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);
|
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||||
@@ -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_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_WIDTH = 'netcatty_terminal_host_tree_width_v1';
|
||||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_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';
|
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). */
|
/** 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';
|
export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1';
|
||||||
|
|||||||
Reference in new issue
Block a user