Skip to content
Merged
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
627 changes: 627 additions & 0 deletions apps/netscli-gui/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/netscli-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/selenium-webdriver": "^4.35.5",
"@vitejs/plugin-react": "^6.0.1",
"edgedriver": "^6.3.0",
"jsdom": "^26.1.0",
"selenium-webdriver": "^4.44.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
Expand Down
2 changes: 1 addition & 1 deletion apps/netscli-gui/src/components/shell/AppDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function AppDialogs({
onSelectTab={workspace.setActiveTabId}
onSelectRow={(tabId, rowIndex) => {
workspace.setActiveTabId(tabId);
window.setTimeout(() => workspace.selectRow(rowIndex), 0);
workspace.selectRowInTab(tabId, rowIndex);
}}
/>
)}
Expand Down
36 changes: 21 additions & 15 deletions apps/netscli-gui/src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';

import type { WorkspaceModel } from '../workspace/types';

Expand All @@ -25,6 +25,16 @@ export function useKeyboardShortcuts({
}) {
const activeTab = workspace.activeTab;

// The handler is rebuilt every render (it closes over props that change),
// but the *listener* is attached once and reads the latest handler through
// this ref.
//
// Previously the deps array included `workspace` — a fresh object on every
// render — plus two fresh closures, so the 3-second network-stats poll
// re-rendered App and detached/reattached a document-level keydown listener
// continuously (B-18).
const handlerRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);

useEffect(() => {
function isEditableTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && Boolean(target.closest('input,textarea,[contenteditable="true"]'));
Expand Down Expand Up @@ -110,19 +120,15 @@ export function useKeyboardShortcuts({
}
}

document.addEventListener('keydown', handleKeyboardShortcuts);
return () => document.removeEventListener('keydown', handleKeyboardShortcuts);
}, [
activeTab,
focusResultFilter,
openMenu,
requestRun,
setOpenMenu,
setSettingsOpen,
settingsOpen,
setWorkspaceSearchOpen,
workspace,
workspaceSearchOpen,
]);
// No deps: this runs after every render and only assigns a ref, which is
// far cheaper than swapping a DOM listener.
handlerRef.current = handleKeyboardShortcuts;
});

useEffect(() => {
const listener = (event: KeyboardEvent) => handlerRef.current?.(event);
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, []);
}

69 changes: 69 additions & 0 deletions apps/netscli-gui/src/tools/presentation/table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';

import { filterAndSortRows } from './table';
import type { ResultRow, WorkspaceTab } from '../types';

// Minimal rows: `filterAndSortRows` matches unkeyed terms against
// `searchText` and keyed terms against `data`.
const ROWS: ResultRow[] = [
{ data: { port: 22, service: 'ssh', status: 'open' }, searchText: '22 ssh open', raw: {} },
{ data: { port: 80, service: 'nginx', status: 'open' }, searchText: '80 nginx open', raw: {} },
{ data: { port: 443, service: 'nginx', status: 'open' }, searchText: '443 nginx open', raw: {} },
{ data: { port: 8080, service: 'proxy', status: 'closed' }, searchText: '8080 proxy closed', raw: {} },
] as unknown as ResultRow[];

const TAB = { kind: 'scan', sortKey: undefined, sortDir: 'asc' } as unknown as WorkspaceTab;

const filter = (query: string) => filterAndSortRows(ROWS, TAB, query);

describe('result filter quoting', () => {
it('matches an unquoted term', () => {
expect(filter('open')).toHaveLength(3);
});

it('matches a fully quoted term with either quote style', () => {
expect(filter('"open"')).toHaveLength(3);
expect(filter("'open'")).toHaveLength(3);
});

// M-6. Typing `"open"` passes through `"open` on the way. That intermediate
// state used to tokenize as the literal `"open`, which matched nothing — so
// the table blinked to empty mid-keystroke and stayed there if the user
// never closed the quote.
//
// Reproduced live before the fix: open -> 4 rows, 'open' -> 4, 'open -> 0.
it('treats an unterminated quote as if it had not been typed yet', () => {
expect(filter('"open')).toHaveLength(3);
expect(filter("'open")).toHaveLength(3);
});

it('still splits terms while a quote is dangling', () => {
// `"nginx open` must behave as two terms, not one literal phrase.
expect(filter('"nginx open')).toHaveLength(2);
});

it('keeps a closing quote from swallowing the term', () => {
expect(filter('open"')).toHaveLength(3);
});

it('does not treat a mid-word apostrophe as a quote', () => {
// A lone apostrophe inside a word must stay part of the search text,
// otherwise names like o'brien become unsearchable.
expect(filter("don't")).toHaveLength(0);
expect(filter('nginx')).toHaveLength(2);
});

it('ignores a lone quote character rather than matching nothing', () => {
expect(filter('"')).toHaveLength(ROWS.length);
expect(filter("'")).toHaveLength(ROWS.length);
});

it('supports quoted phrases containing spaces', () => {
// A quoted phrase is one contiguous substring match against searchText.
expect(filter('"nginx open"')).toHaveLength(2);
expect(filter('"443 nginx"')).toHaveLength(1);
// Non-adjacent words do not match as a phrase, unlike two bare terms.
expect(filter('"open 443"')).toHaveLength(0);
expect(filter('open 443')).toHaveLength(1);
});
});
27 changes: 24 additions & 3 deletions apps/netscli-gui/src/tools/presentation/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,22 @@ function parseFilter(input: string): FilterToken[] {

function tokenizeFilter(query: string): string[] {
const tokens: string[] = [];
const chars = Array.from(query);
let current = '';
let quote: '"' | "'" | null = null;
let quoteStart = -1;

for (const char of query) {
for (let index = 0; index < chars.length; index += 1) {
const char = chars[index];
if ((char === '"' || char === "'") && quote === null) {
quote = char;
quoteStart = index;
current += char;
continue;
}
if (char === quote) {
quote = null;
quoteStart = -1;
current += char;
continue;
}
Expand All @@ -90,6 +95,20 @@ function tokenizeFilter(query: string): string[] {
current += char;
}

// An unterminated quote means the user is mid-typing on the way to a closing
// one. Treating it as a real quote made the whole tail a single literal
// token — so `'open` searched for `'open` and silently returned no rows
// where `open` returned four (M-6).
//
// Re-tokenizing without the dangling quote makes typing `"open 22` behave
// like `open 22` until the quote is closed. Each pass removes one character,
// so the recursion terminates. A quote *inside* a word (`don't`) never opens
// one here, so apostrophes in search text still work.
if (quote !== null && quoteStart >= 0) {
chars.splice(quoteStart, 1);
return tokenizeFilter(chars.join(''));
}

if (current.trim()) tokens.push(current.trim());
return tokens;
}
Expand All @@ -112,9 +131,11 @@ function keysFor(key: string): string[] {
}

function stripQuotes(value: string): string {
// The length guard matters: a lone `"` both starts and ends with a quote,
// and `slice(1, -1)` on it would silently produce an empty term.
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
return value.slice(1, -1);
}
Expand Down
16 changes: 9 additions & 7 deletions apps/netscli-gui/src/workspace/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,25 @@ export function exportSelectedRows(
);
}

export async function copyRowsDetails(rows: ResultRow[]) {
if (rows.length === 0) return;
const details = rows
// These build the text and nothing more. They used to write to the clipboard
// themselves and swallow the outcome, which left the caller reporting success
// unconditionally (B-19). Copy reporting now lives in one place, in
// `useResultActions.copyToClipboard`.

export function formatRowsDetails(rows: ResultRow[]): string {
return rows
.map((row, index) => {
const body = Object.entries(row.data)
.map(([key, value]) => `${key}: ${value ?? ''}`)
.join('\n');
return rows.length === 1 ? body : `Row ${index + 1}\n${body}`;
})
.join('\n\n');
await navigator.clipboard?.writeText(details).catch(() => undefined);
}

export async function copyRowsRaw(rows: ResultRow[]) {
if (rows.length === 0) return;
export function formatRowsRaw(rows: ResultRow[]): string {
const payload = rows.length === 1 ? rows[0].raw : rows.map((row) => row.raw);
await navigator.clipboard?.writeText(JSON.stringify(payload, null, 2)).catch(() => undefined);
return JSON.stringify(payload, null, 2);
}

function exportText(
Expand Down
2 changes: 2 additions & 0 deletions apps/netscli-gui/src/workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface WorkspaceModel {
patchTab: (id: string, patch: Partial<WorkspaceTab>) => void;
patchForm: (id: string, key: string, value: string) => void;
selectRow: (index: number, mode?: RowSelectionMode) => void;
/** Select a row in a named tab, which need not be the active one. */
selectRowInTab: (tabId: string, index: number) => void;
selectAllRows: () => void;
addTab: (kind: ToolKind) => void;
openHostTool: (kind: 'scan' | 'inspect', host: string) => void;
Expand Down
47 changes: 31 additions & 16 deletions apps/netscli-gui/src/workspace/useResultActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { createTab, TOOL_CONFIG } from '../tools/registry';
import type { ResultColumn, ResultRow, WorkspaceTab } from '../tools/types';
import {
buildResultBundle,
copyRowsDetails,
copyRowsRaw,
formatRowsDetails,
formatRowsRaw,
exportCurrentResult,
exportSelectedRows,
parseResultBundle,
Expand Down Expand Up @@ -94,16 +94,37 @@ export function useResultActions({
}
}

/**
* Copy text, reporting whether it actually landed.
*
* The previous shape — `navigator.clipboard?.writeText(x).catch(() =>
* undefined)` followed by an unconditional "copied" toast — reported
* success in two failing cases: a rejected write (swallowed by the catch)
* and a missing `navigator.clipboard` (the optional chain made it a silent
* no-op). The export path already reports its failures; this matches it.
*/
async function copyToClipboard(label: string, text: string) {
if (!navigator.clipboard) {
showToast({ message: `${label} failed: clipboard unavailable`, kind: 'interaction' });
return;
}
try {
await navigator.clipboard.writeText(text);
showToast({ message: `${label} copied`, kind: 'interaction' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showToast({ message: `${label} copy failed: ${message}`, kind: 'interaction' });
}
}

async function copyCommand() {
if (!commandPreview) return;
await navigator.clipboard?.writeText(commandPreview).catch(() => undefined);
showToast({ message: 'Command copied', kind: 'interaction' });
await copyToClipboard('Command', commandPreview);
}

async function copyCellValue(label: string, value: string) {
if (!value) return;
await navigator.clipboard?.writeText(value).catch(() => undefined);
showToast({ message: `${label} copied`, kind: 'interaction' });
await copyToClipboard(label, value);
}

async function openCaptureFile(path: string) {
Expand All @@ -123,21 +144,15 @@ export function useResultActions({
async function copySelectedDetails() {
const rowsToCopy = selectedRows.length > 0 ? selectedRows : selectedRow ? [selectedRow] : [];
if (rowsToCopy.length === 0) return;
await copyRowsDetails(rowsToCopy);
showToast({
message: rowsToCopy.length === 1 ? 'Details copied' : `${rowsToCopy.length} rows copied`,
kind: 'interaction',
});
const label = rowsToCopy.length === 1 ? 'Details' : `${rowsToCopy.length} rows`;
await copyToClipboard(label, formatRowsDetails(rowsToCopy));
}

async function copySelectedRaw() {
const rowsToCopy = selectedRows.length > 0 ? selectedRows : selectedRow ? [selectedRow] : [];
if (rowsToCopy.length === 0) return;
await copyRowsRaw(rowsToCopy);
showToast({
message: rowsToCopy.length === 1 ? 'Raw row copied' : `Raw ${rowsToCopy.length} rows copied`,
kind: 'interaction',
});
const label = rowsToCopy.length === 1 ? 'Raw row' : `Raw ${rowsToCopy.length} rows`;
await copyToClipboard(label, formatRowsRaw(rowsToCopy));
}

return {
Expand Down
Loading
Loading