[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

50
lib/commandBlocklist.cjs Normal file
View File

@@ -0,0 +1,50 @@
"use strict";
const TABLE = require("./commandBlocklist.json");
const COMMON_PATTERNS = [...TABLE.common];
const POSIX_NATIVE_PATTERNS = [...TABLE.posixNative];
const POSIX_PATTERNS = [...TABLE.posix];
const POWERSHELL_PATTERNS = [...TABLE.powershell];
const PATTERN_GROUPS = {
common: COMMON_PATTERNS,
posixNative: POSIX_NATIVE_PATTERNS,
posix: POSIX_PATTERNS,
powershell: POWERSHELL_PATTERNS,
};
/** Flat union of every default pattern: the strictest selection. */
const DEFAULT_COMMAND_BLOCKLIST = [
...COMMON_PATTERNS,
...POSIX_NATIVE_PATTERNS,
...POSIX_PATTERNS,
...POWERSHELL_PATTERNS,
];
const DEFAULT_PATTERN_SET = new Set(DEFAULT_COMMAND_BLOCKLIST);
/**
* Shell kinds as produced by lib/localShell.cjs / session shell detection.
* Unknown / empty kinds intentionally fall back to the full list so callers
* that cannot classify a session keep today's strict behavior.
*/
function selectDefaultBlocklistPatterns(shellKind) {
const groupNames = TABLE.shellGroups[String(shellKind || "").toLowerCase()];
return Array.isArray(groupNames)
? groupNames.flatMap((name) => PATTERN_GROUPS[name] || [])
: [...DEFAULT_COMMAND_BLOCKLIST];
}
function isDefaultBlocklistPattern(pattern) {
return DEFAULT_PATTERN_SET.has(pattern);
}
module.exports = DEFAULT_COMMAND_BLOCKLIST;
module.exports.DEFAULT_COMMAND_BLOCKLIST = DEFAULT_COMMAND_BLOCKLIST;
module.exports.COMMON_PATTERNS = COMMON_PATTERNS;
module.exports.POSIX_NATIVE_PATTERNS = POSIX_NATIVE_PATTERNS;
module.exports.POSIX_PATTERNS = POSIX_PATTERNS;
module.exports.POWERSHELL_PATTERNS = POWERSHELL_PATTERNS;
module.exports.DEFAULT_PATTERN_SET = DEFAULT_PATTERN_SET;
module.exports.selectDefaultBlocklistPatterns = selectDefaultBlocklistPatterns;
module.exports.isDefaultBlocklistPattern = isDefaultBlocklistPattern;

37
lib/commandBlocklist.json Normal file
View File

@@ -0,0 +1,37 @@
{
"common": [
"\\brm\\s+(?=[^\\n;&|]*(?:-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\\b|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\\b|-[a-zA-Z]*r[a-zA-Z]*\\b[^\\n;&|]*-[a-zA-Z]*f[a-zA-Z]*\\b|-[a-zA-Z]*f[a-zA-Z]*\\b[^\\n;&|]*-[a-zA-Z]*r[a-zA-Z]*\\b|--recursive\\b[^\\n;&|]*(?:--force\\b|-[a-zA-Z]*f[a-zA-Z]*\\b)|(?:--force\\b|-[a-zA-Z]*f[a-zA-Z]*\\b)[^\\n;&|]*--recursive\\b))",
"\\b(shutdown|reboot|poweroff|halt)\\b"
],
"posixNative": [
"\\bmkfs\\.",
"\\bdd\\s+if=.*\\s+of=/dev/",
">\\s*/dev/sd",
"\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/",
"\\bmv\\s+/\\s",
"\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b",
"\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b",
"base64.*\\|.*(?:ba)?sh"
],
"posix": [
":\\(\\)\\{\\s*:\\|:\\&\\s*\\};:",
":\\s*>\\s*/etc/",
"\\beval\\b",
"\\$\\(",
"`.+`"
],
"powershell": [
"\\b(Remove-Item|del|erase|rd|ri|rm|rmdir)\\b[^\\n;&|]*-(rec\\w*|re|r)\\b[^\\n;&|]*-(fo\\w*|f)\\b",
"\\b(Remove-Item|del|erase|rd|ri|rm|rmdir)\\b[^\\n;&|]*-(fo\\w*|f)\\b[^\\n;&|]*-(rec\\w*|re|r)\\b",
"\\b(iex|Invoke-Expression)\\b",
"\\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\\b[^\\n;&|]*\\|\\s*(iex|Invoke-Expression)\\b",
"\\bSet-ExecutionPolicy\\b[^\\n;&|]*\\b(Bypass|Unrestricted)\\b",
"\\b(Format-Volume|Clear-Disk|Initialize-Disk|Stop-Computer|Restart-Computer)\\b"
],
"shellGroups": {
"powershell": ["common", "posixNative", "powershell"],
"cmd": ["common", "posixNative"],
"posix": ["common", "posixNative", "posix"],
"fish": ["common", "posixNative", "posix"]
}
}

23
lib/customCss.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const root = new URL('..', import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), 'utf8');
}
test('custom CSS helper uses a single stable style element id', () => {
const source = readProjectFile('lib/customCss.ts');
assert.match(source, /netcatty-custom-css/);
assert.match(source, /styleEl\.textContent = css/);
});
test('settings state applies custom CSS through the shared helper', () => {
const source = readProjectFile('application/state/useSettingsState.ts');
assert.match(source, /applyCustomCssToDocument\(customCSS\)/);
});

14
lib/customCss.ts Normal file
View File

@@ -0,0 +1,14 @@
const CUSTOM_CSS_STYLE_ID = 'netcatty-custom-css';
/** Inject or update the user custom CSS style block in document.head. */
export function applyCustomCssToDocument(css: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = CUSTOM_CSS_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
}

View File

@@ -0,0 +1,224 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
extractPrimaryFamily,
detectInstalledWithContext,
isFontInstalled,
setSystemFamilies,
hasAuthoritativeData,
clearFontAvailabilityCache,
subscribeFontAvailability,
getFontAvailabilityVersion,
bundledFamiliesInStack,
} from './fontAvailability';
describe('bundledFamiliesInStack', () => {
it('returns bundled webfonts present in a composed stack, in order', () => {
const stack = 'Consolas, "JetBrains Mono", "Sarasa Mono SC", monospace';
assert.deepEqual(bundledFamiliesInStack(stack), ['JetBrains Mono', 'Sarasa Mono SC']);
});
it('returns nothing when only system fonts are referenced', () => {
assert.deepEqual(bundledFamiliesInStack('Menlo, monospace'), []);
});
it('ignores the generic monospace keyword and dedupes', () => {
const stack = '"JetBrains Mono", "JetBrains Mono", monospace';
assert.deepEqual(bundledFamiliesInStack(stack), ['JetBrains Mono']);
});
it('is case-insensitive on family names', () => {
assert.deepEqual(bundledFamiliesInStack('"jetbrains mono"'), ['JetBrains Mono']);
});
});
describe('extractPrimaryFamily', () => {
it('strips quotes from a quoted name', () => {
assert.equal(extractPrimaryFamily('"Fira Code", monospace'), 'Fira Code');
});
it('returns unquoted single-word names as-is', () => {
assert.equal(extractPrimaryFamily('Menlo, monospace'), 'Menlo');
});
it('returns the first family in a list', () => {
assert.equal(
extractPrimaryFamily('"Source Code Pro", "Fira Code", monospace'),
'Source Code Pro',
);
});
it('handles a single name without comma', () => {
assert.equal(extractPrimaryFamily('Iosevka'), 'Iosevka');
});
});
function makeContextWithInstalledFamilies(installed: Set<string>) {
// Mock canvas measurement: each generic fallback has a stable width;
// a "real" installed font produces a different width per fallback.
// Collision-resistant: position-weighted polynomial hash.
const widthFor = (family: string): number => {
let h = 0;
for (let i = 0; i < family.length; i++) {
h = (h * 31 + family.charCodeAt(i)) >>> 0;
}
return 100 + (h % 9973);
};
return {
measureText: (font: string, _text: string) => {
const match = font.match(/^\d+px\s+(.+)$/);
if (!match) return 0;
const familyList = match[1];
const families = familyList
.split(',')
.map((f) => f.trim().replace(/^["']|["']$/g, ''));
for (const f of families) {
if (installed.has(f) || ['serif', 'sans-serif', 'monospace'].includes(f)) {
return widthFor(f);
}
}
return 0;
},
};
}
describe('detectInstalledWithContext (canvas fallback)', () => {
it('detects an installed font (width differs from all 3 generic fallbacks)', () => {
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
assert.equal(detectInstalledWithContext('Fira Code', ctx), true);
});
it('rejects a non-installed font (falls through to fallback)', () => {
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
assert.equal(detectInstalledWithContext('Definitely Not A Font', ctx), false);
});
it('treats KNOWN_BUNDLED_FAMILIES as installed regardless of canvas evidence', () => {
const ctx = makeContextWithInstalledFamilies(new Set());
assert.equal(detectInstalledWithContext('JetBrains Mono', ctx), true);
assert.equal(detectInstalledWithContext('Sarasa Mono SC', ctx), true);
});
it('treats a font as installed when it matches one generic but differs from the others', () => {
// Regression guard for codex P2 review on PR #940: on macOS the
// `monospace` generic resolves to Menlo, so measure(`"Menlo", monospace`)
// equals measure(`monospace`). The detector must NOT report Menlo
// as uninstalled just because of that single collision — it should
// recognize installation via the other two generic baselines.
const ctx = {
measureText: (font: string): number => {
// "Menlo", X → Menlo's metrics (always 100, regardless of fallback)
if (font.includes('"Menlo"')) return 100;
// Generic baselines
if (font === '72px serif') return 50;
if (font === '72px sans-serif') return 80;
if (font === '72px monospace') return 100; // identical to Menlo
// Unknown family followed by a generic → falls to that generic
const tail = font.split(',').pop()?.trim() ?? '';
if (tail === 'serif') return 50;
if (tail === 'sans-serif') return 80;
if (tail === 'monospace') return 100;
return 0;
},
};
assert.equal(detectInstalledWithContext('Menlo', ctx), true);
});
it('still reports a clearly-uninstalled font as missing even with the looser rule', () => {
// "Some" semantics must not introduce false positives for fonts
// that genuinely aren't installed — those fall through to each
// generic and match all three baselines.
const ctx = makeContextWithInstalledFamilies(new Set(['Menlo']));
assert.equal(detectInstalledWithContext('Definitely Not Installed', ctx), false);
});
});
describe('isFontInstalled with authoritative system data', () => {
beforeEach(() => {
clearFontAvailabilityCache();
});
it('returns true for bundled families even without authoritative data', () => {
assert.equal(hasAuthoritativeData(), false);
assert.equal(isFontInstalled('JetBrains Mono'), true);
assert.equal(isFontInstalled('Sarasa Mono SC'), true);
});
it('answers from authoritative set once setSystemFamilies has run', () => {
setSystemFamilies(new Set(['menlo', 'fira code']));
assert.equal(hasAuthoritativeData(), true);
assert.equal(isFontInstalled('Menlo'), true);
assert.equal(isFontInstalled('Fira Code'), true);
assert.equal(isFontInstalled('Sarasa Mono SC'), true, 'bundled wins over set');
assert.equal(isFontInstalled('PingFang SC'), false, 'not in authoritative set');
assert.equal(isFontInstalled('Programmer Fonts'), false, 'fictitious name');
});
it('lookup is case-insensitive (set stores lowercase)', () => {
setSystemFamilies(new Set(['microsoft yahei ui']));
assert.equal(isFontInstalled('Microsoft YaHei UI'), true);
assert.equal(isFontInstalled('MICROSOFT YAHEI UI'), true);
});
it('falls back to safe-default (true) without DOM and without authoritative data', () => {
assert.equal(hasAuthoritativeData(), false);
assert.equal(isFontInstalled('Some Unknown Font'), true);
});
it('a null authoritative set means we re-enter fallback mode', () => {
setSystemFamilies(new Set(['menlo']));
assert.equal(hasAuthoritativeData(), true);
setSystemFamilies(null);
assert.equal(hasAuthoritativeData(), false);
});
});
describe('font availability subscription', () => {
beforeEach(() => {
clearFontAvailabilityCache();
});
it('notifies subscribers when setSystemFamilies is called', () => {
// Regression guard for codex P2 review on PR #940:
// TerminalCjkFontSelect memoizes visibleOptions on [value] but the
// filter calls isFontInstalled which depends on systemFamilies.
// Subscribers wired via useSyncExternalStore must fire so memos
// recompute when authoritative data arrives.
let calls = 0;
const unsubscribe = subscribeFontAvailability(() => {
calls += 1;
});
setSystemFamilies(new Set(['menlo']));
assert.equal(calls, 1);
setSystemFamilies(new Set(['menlo', 'fira code']));
assert.equal(calls, 2);
setSystemFamilies(null);
assert.equal(calls, 3);
unsubscribe();
setSystemFamilies(new Set(['menlo']));
assert.equal(calls, 3, 'unsubscribe stops notifications');
});
it('version monotonically increases on each setSystemFamilies call', () => {
const v0 = getFontAvailabilityVersion();
setSystemFamilies(new Set(['menlo']));
const v1 = getFontAvailabilityVersion();
setSystemFamilies(new Set(['menlo', 'fira code']));
const v2 = getFontAvailabilityVersion();
assert.ok(v1 > v0, 'first call bumps version');
assert.ok(v2 > v1, 'second call bumps version');
});
it('clearFontAvailabilityCache also notifies subscribers', () => {
let calls = 0;
subscribeFontAvailability(() => {
calls += 1;
});
setSystemFamilies(new Set(['menlo']));
const after = calls;
clearFontAvailabilityCache();
assert.ok(calls > after, 'clear notifies too');
});
});

189
lib/fontAvailability.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* Decides whether a CSS font-family is actually rendered (system-installed
* or loaded via @font-face) on the current machine. Used to filter the
* terminal font dropdowns.
*
* Why not document.fonts.check(): in Chromium it returns true for any
* syntactically-valid family name regardless of whether that font is
* actually installed (a deliberate fingerprinting-mitigation choice), so
* it produces massive false positives. We rely instead on:
*
* 1. KNOWN_BUNDLED_FAMILIES — fonts we ship via @font-face / @fontsource.
* Always true.
* 2. setSystemFamilies() — an authoritative Set populated by fontStore
* after Local Font Access API returns. Membership lookup. When
* populated, this is the only signal needed for system fonts.
* 3. Canvas width fallback — used only before setSystemFamilies() runs
* or when the Font Access API is unavailable / denied. A font counts
* as installed only when its rendered width differs from ALL three
* generic fallbacks (serif, sans-serif, monospace).
*/
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
'JetBrains Mono', // @fontsource/jetbrains-mono (400, 500, 600)
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
]);
const KNOWN_BUNDLED_BY_LOWER = new Map<string, string>(
[...KNOWN_BUNDLED_FAMILIES].map((name) => [name.toLowerCase(), name]),
);
/**
* The bundled webfonts (shipped via @font-face / @fontsource) that appear
* in a composed font-family stack, in stack order and de-duplicated, with
* their canonical casing. Used to explicitly preload the fonts a terminal
* actually renders with so xterm can remeasure the cell grid once they
* load — these faces use `font-display: swap`, so without an explicit
* preload + remeasure their late swap garbles the grid until a manual
* resize (#1647). System fonts and the generic `monospace` keyword are
* skipped.
*/
export function bundledFamiliesInStack(fontFamilyCss: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const token of splitFontFamilyList(fontFamilyCss)) {
const bare = token.replace(/^["']|["']$/g, '').toLowerCase();
const canonical = KNOWN_BUNDLED_BY_LOWER.get(bare);
if (!canonical || seen.has(canonical)) continue;
seen.add(canonical);
result.push(canonical);
}
return result;
}
let systemFamilies: Set<string> | null = null;
let availabilityVersion = 0;
const listeners = new Set<() => void>();
/**
* "Fira Code", monospace → Fira Code | Menlo, monospace → Menlo.
* Quote-aware so a single family name containing commas (CSS permits
* `"Foo, Inc. Mono"`) survives intact instead of being truncated.
*/
export function extractPrimaryFamily(familyCssString: string): string {
const first = splitFontFamilyList(familyCssString)[0] ?? '';
return first.replace(/^["']|["']$/g, '');
}
/**
* Called by fontStore once Local Font Access API has returned the full
* list of installed family names (lower-cased). After this runs,
* isFontInstalled answers from this authoritative set rather than from
* canvas measurement.
*
* Notifies subscribers so React components memoizing on availability
* can recompute (e.g. dropdown filters that called isFontInstalled
* before authoritative data arrived).
*/
export function setSystemFamilies(families: Set<string> | null): void {
systemFamilies = families;
availabilityVersion += 1;
for (const listener of listeners) listener();
}
/** True when authoritative system data is available; canvas fallback skipped. */
export function hasAuthoritativeData(): boolean {
return systemFamilies !== null;
}
/**
* Subscribe to changes in font availability. Returns an unsubscribe fn.
* Used together with getFontAvailabilityVersion() and
* useSyncExternalStore in React components that filter on
* isFontInstalled() — so their useMemo dependencies invalidate when
* the authoritative install set is populated or cleared.
*/
export function subscribeFontAvailability(callback: () => void): () => void {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}
/** Monotonically increasing version, bumped on every setSystemFamilies. */
export function getFontAvailabilityVersion(): number {
return availabilityVersion;
}
const cache = new Map<string, boolean>();
interface DetectionContext {
measureText: (font: string, text: string) => number;
}
const TEST_STRING = 'mmmmmmmmmmlli';
const FALLBACK_FAMILIES = ['serif', 'sans-serif', 'monospace'] as const;
function buildBrowserContext(): DetectionContext | null {
if (typeof document === 'undefined') return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return {
measureText: (font, text) => {
ctx.font = font;
return ctx.measureText(text).width;
},
};
}
/**
* Pure detection logic — exported for testing without a DOM.
*
* Returns true if rendering the probe string against ANY of the three
* generic fallbacks (serif, sans-serif, monospace) with the target font
* listed first produces a different width than the bare generic. We use
* "some" rather than "every" because some platform defaults make a
* generic family literally identical to a real installed font — for
* example on macOS the `monospace` generic resolves to Menlo, so
* measure("'Menlo', monospace") === measure("monospace"). Requiring all
* three to differ would then falsely report Menlo as missing. A truly
* uninstalled font falls through to each generic in turn and matches
* all three, so "some" still correctly returns false for those.
*/
export function detectInstalledWithContext(
family: string,
ctx: DetectionContext,
): boolean {
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
return FALLBACK_FAMILIES.some((fb) => {
const baseWidth = ctx.measureText(`72px ${fb}`, TEST_STRING);
const targetWidth = ctx.measureText(`72px "${family}", ${fb}`, TEST_STRING);
return baseWidth !== targetWidth;
});
}
export function isFontInstalled(family: string): boolean {
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
// Authoritative path: Local Font Access API enumeration.
if (systemFamilies) {
return systemFamilies.has(family.toLowerCase());
}
// Fallback path: canvas measurement, cached per family. Only used
// before setSystemFamilies has run, or when the API is denied.
const cached = cache.get(family);
if (cached !== undefined) return cached;
const ctx = buildBrowserContext();
// No DOM (SSR / tests) and no authoritative data → treat as available
// so we don't aggressively hide everything.
if (!ctx) {
cache.set(family, true);
return true;
}
const result = detectInstalledWithContext(family, ctx);
cache.set(family, result);
return result;
}
export function clearFontAvailabilityCache(): void {
cache.clear();
systemFamilies = null;
availabilityVersion += 1;
for (const listener of listeners) listener();
}

View File

@@ -0,0 +1,93 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
isBoldWeightDistinctWithContext,
pickNearestBundledWeight,
resolveFontWeightBold,
} from './fontWeightAvailability';
function makeWeightContext(weightsByFamily: Record<string, Partial<Record<number, number>>>) {
return {
measureText: (font: string, text: string) => {
const match = font.match(/^(\d+)\s+\d+px\s+"?([^",]+)"?,/);
const weight = match ? Number(match[1]) : 400;
const family = match?.[2] ?? '';
const width = weightsByFamily[family]?.[weight] ?? weightsByFamily[family]?.[400] ?? 100;
return {
width: width * text.length,
actualBoundingBoxAscent: 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
}
describe('pickNearestBundledWeight', () => {
it('returns the desired weight when bundled', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 600, 400), 600);
});
it('falls back to the nearest heavier bundled weight', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 700, 400), 600);
});
it('returns normal weight when nothing heavier is bundled', () => {
assert.equal(pickNearestBundledWeight([400], 700, 400), 400);
});
});
describe('isBoldWeightDistinctWithContext', () => {
it('detects a real bold face via width differences', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 12 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('detects a real bold face via ascent differences', () => {
const ctx = {
measureText: (font: string, text: string) => {
const isBold = font.startsWith('700 ');
return {
width: text.length * 10,
actualBoundingBoxAscent: isBold ? 12 : 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('rejects unavailable bold weights that collapse to the normal face', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 10 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), false);
});
});
describe('resolveFontWeightBold', () => {
it('caps bundled JetBrains Mono bold at 600 when 700 is requested', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 400,
desiredBoldWeight: 700,
fontSize: 14,
}),
600,
);
});
it('returns normal weight when bold is not heavier than normal', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 600,
desiredBoldWeight: 500,
fontSize: 14,
}),
600,
);
});
});

View File

@@ -0,0 +1,104 @@
import { extractPrimaryFamily } from './fontAvailability';
/** Weights actually shipped via @fontsource in index.tsx. */
export const BUNDLED_FONT_WEIGHTS: Readonly<Record<string, readonly number[]>> = {
'JetBrains Mono': [400, 500, 600],
};
export type FontWeightMeasureContext = {
measureText: (font: string, text: string) => TextMetrics;
};
const BOLD_PROBE = 'WMwm0123456789';
export function pickNearestBundledWeight(
available: readonly number[],
desired: number,
normal: number,
): number {
if (available.includes(desired)) return desired;
const heavier = available.filter((weight) => weight > normal);
if (heavier.length === 0) return normal;
return heavier.reduce((best, weight) =>
Math.abs(weight - desired) < Math.abs(best - desired) ? weight : best,
);
}
/**
* True when rendering `boldWeight` produces measurably different glyphs than
* `normalWeight` for `family`. Unlike document.fonts.check(), this does not
* false-positive on syntactically valid but unavailable families/weights in
* Chromium (see fontAvailability.ts).
*/
export function isBoldWeightDistinctWithContext(
family: string,
normalWeight: number,
boldWeight: number,
fontSize: number,
ctx: FontWeightMeasureContext,
): boolean {
if (boldWeight <= normalWeight) return false;
const quoted = /\s/.test(family) ? `"${family}"` : family;
const normalFont = `${normalWeight} ${fontSize}px ${quoted}, monospace`;
const boldFont = `${boldWeight} ${fontSize}px ${quoted}, monospace`;
const normalMetrics = ctx.measureText(normalFont, BOLD_PROBE);
const boldMetrics = ctx.measureText(boldFont, BOLD_PROBE);
if (Math.abs(boldMetrics.width - normalMetrics.width) > 0.01) return true;
const normalAscent = normalMetrics.actualBoundingBoxAscent ?? 0;
const boldAscent = boldMetrics.actualBoundingBoxAscent ?? 0;
if (Math.abs(boldAscent - normalAscent) > 0.01) return true;
const normalDescent = normalMetrics.actualBoundingBoxDescent ?? 0;
const boldDescent = boldMetrics.actualBoundingBoxDescent ?? 0;
return Math.abs(boldDescent - normalDescent) > 0.01;
}
function buildBrowserMeasureContext(): FontWeightMeasureContext | null {
if (typeof document === 'undefined') return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return {
measureText: (font, text) => {
ctx.font = font;
return ctx.measureText(text);
},
};
}
/**
* Resolve the boldest weight xterm can safely rasterize for the primary font.
* Falls back to `normalWeight` when the requested bold face is unavailable.
*/
export function resolveFontWeightBold(args: {
fontFamilyCss: string;
normalWeight: number;
desiredBoldWeight: number;
fontSize: number;
}): number {
const { fontFamilyCss, normalWeight, desiredBoldWeight, fontSize } = args;
if (desiredBoldWeight <= normalWeight) return normalWeight;
const primary = extractPrimaryFamily(fontFamilyCss);
const bundled = BUNDLED_FONT_WEIGHTS[primary];
if (bundled) {
return pickNearestBundledWeight(bundled, desiredBoldWeight, normalWeight);
}
const ctx = buildBrowserMeasureContext();
if (!ctx) return desiredBoldWeight;
return isBoldWeightDistinctWithContext(
primary,
normalWeight,
desiredBoldWeight,
fontSize,
ctx,
)
? desiredBoldWeight
: normalWeight;
}

136
lib/localFonts.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
getAllSystemFontFamilies,
getAllSystemFontFamilyNames,
getMonospaceFonts,
__resetLocalFontsCacheForTesting,
} from './localFonts';
interface MockWindow {
queryLocalFonts: () => Promise<Array<{ family: string }>>;
}
function installMockWindow(impl: MockWindow['queryLocalFonts']): void {
(globalThis as unknown as { window: MockWindow }).window = {
queryLocalFonts: impl,
};
}
function uninstallMockWindow(): void {
delete (globalThis as unknown as { window?: MockWindow }).window;
}
describe('queryLocalFonts deduplication', () => {
beforeEach(() => {
__resetLocalFontsCacheForTesting();
});
afterEach(() => {
uninstallMockWindow();
__resetLocalFontsCacheForTesting();
});
it('coalesces concurrent calls into a single Local Font Access API invocation', async () => {
// Regression guard for codex P2 review on PR #940: fontStore.initialize
// calls getMonospaceFonts() and getAllSystemFontFamilies() in
// Promise.all; both must share one underlying queryLocalFonts() call,
// not race and fire two prompts / two requests.
let callCount = 0;
installMockWindow(async () => {
callCount++;
// Tiny tick so the two callers truly overlap in time.
await new Promise<void>((r) => setTimeout(r, 5));
return [
{ family: 'Menlo' },
{ family: 'Fira Code' },
{ family: 'PingFang SC' },
];
});
const [monoFonts, allFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
assert.equal(callCount, 1, 'queryLocalFonts must be invoked exactly once');
assert.ok(allFamilies !== null);
assert.equal(allFamilies?.has('menlo'), true);
assert.equal(allFamilies?.has('pingfang sc'), true);
// Mono filter keeps only the monospace-named family.
assert.equal(
monoFonts.some((f) => f.name === 'Fira Code'),
true,
);
});
it('a second sequential call also reuses the resolved promise (no second API call)', async () => {
let callCount = 0;
installMockWindow(async () => {
callCount++;
return [{ family: 'Menlo' }];
});
await getAllSystemFontFamilies();
await getAllSystemFontFamilies();
await getMonospaceFonts();
assert.equal(callCount, 1);
});
it('returns display-ready family names with stable casing and case-insensitive deduplication', async () => {
installMockWindow(async () => [
{ family: 'PingFang SC' },
{ family: 'pingfang sc' },
{ family: 'Sarasa Mono SC' },
{ family: ' Noto Sans Mono CJK SC ' },
{ family: '' },
]);
const result = await getAllSystemFontFamilyNames();
assert.deepEqual(result, [
'Noto Sans Mono CJK SC',
'PingFang SC',
'Sarasa Mono SC',
]);
});
it('returns null authoritative set when Local Font Access API is unavailable', async () => {
// No window installed → API path skipped.
const result = await getAllSystemFontFamilies();
assert.equal(result, null);
});
it('treats an empty desktop font result as unavailable and allows retry', async () => {
let callCount = 0;
installMockWindow(async () => {
callCount++;
return callCount === 1 ? [] : [{ family: 'PingFang SC' }];
});
assert.equal(await getAllSystemFontFamilyNames(), null);
assert.deepEqual(await getAllSystemFontFamilyNames(), ['PingFang SC']);
assert.equal(callCount, 2);
});
it('retries on the next call after a transient failure (does not sticky-cache empty result)', async () => {
// Regression guard for codex P2 review on PR #940: queryLocalFonts
// failure should NOT poison the cache for the rest of the session.
let callCount = 0;
installMockWindow(async () => {
callCount++;
if (callCount === 1) {
throw new Error('transient failure (e.g. LFA permission not ready)');
}
return [{ family: 'Menlo' }, { family: 'Fira Code' }];
});
const first = await getAllSystemFontFamilies();
assert.equal(first, null, 'first failure returns null authoritative set');
// Same module, second invocation: must retry queryLocalFonts.
const second = await getAllSystemFontFamilies();
assert.equal(callCount, 2, 'queryLocalFonts retried on next call');
assert.equal(second?.has('menlo'), true, 'second call sees the fonts');
});
});

222
lib/localFonts.ts Normal file
View File

@@ -0,0 +1,222 @@
import { TerminalFont } from "../infrastructure/config/fonts"
/**
* Type definition for Local Font Access API
* @see https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API
*/
interface LocalFontData {
family: string;
}
/**
* Known monospace font families that don't follow naming conventions.
* These are popular programming/terminal fonts that should be included.
*/
const KNOWN_MONOSPACE_FONTS = new Set([
// Popular programming fonts
'iosevka',
'hack',
'consolas',
'menlo',
'monaco',
'inconsolata',
'mononoki',
'fantasque sans mono',
'anonymous pro',
'liberation mono',
'dejavu sans mono',
'droid sans mono',
'ubuntu mono',
'roboto mono',
'source code pro',
'fira code',
'fira mono',
'jetbrains mono',
'cascadia code',
'cascadia mono',
'victor mono',
'ibm plex mono',
'sf mono',
'operator mono',
'input mono',
'pragmata pro',
'berkeley mono',
'monaspace',
'geist mono',
'comic mono',
'courier',
'courier new',
'lucida console',
'pt mono',
'overpass mono',
'space mono',
'go mono',
'noto sans mono',
'sarasa mono',
'maple mono',
'meslolgs nf',
'symbols nerd font mono',
'symbols nerd font',
]);
/**
* Suffix indicators that suggest a font is monospace
*/
const MONO_SUFFIX_INDICATORS = ['mono', 'monospace', 'code', 'terminal', 'console'];
/**
* Checks if a font family name indicates a monospace font.
* Uses both known font list and suffix matching for comprehensive detection.
*/
function isMonospaceFont(familyName: string): boolean {
const familyLower = familyName.toLowerCase().trim();
// Check against known monospace fonts (exact or partial match)
for (const knownFont of KNOWN_MONOSPACE_FONTS) {
if (familyLower === knownFont || familyLower.startsWith(knownFont + ' ')) {
return true;
}
}
// Check suffix indicators with word boundary
return MONO_SUFFIX_INDICATORS.some(indicator => {
return (
familyLower === indicator ||
familyLower.endsWith(' ' + indicator) ||
familyLower.endsWith('-' + indicator) ||
familyLower.includes(' ' + indicator + ' ')
);
});
}
// Cached unfiltered system family list so we don't hit the Local Font
// Access API more than once per session. Populated as a side effect of
// queryAllSystemFontsOnce(), which both getMonospaceFonts() and
// fontAvailability.ts read.
let allSystemFamiliesCache: Set<string> | null = null;
let allSystemFamilyNamesCache: string[] | null = null;
// In-flight promise dedup: when fontStore.initialize() runs
// getMonospaceFonts() and getAllSystemFontFamilies() in parallel, both
// would otherwise hit queryLocalFonts() before the cache is populated,
// causing two redundant Local Font Access API calls and potential
// permission-handling races. Caching the promise itself means
// concurrent callers await the same single invocation.
let queryPromise: Promise<LocalFontData[]> | null = null;
/** Clears the cached font query so a user-initiated refresh sees changes. */
export function clearLocalFontsCache(): void {
queryPromise = null;
allSystemFamiliesCache = null;
allSystemFamilyNamesCache = null;
}
/** Test alias kept explicit so existing tests communicate their intent. */
export const __resetLocalFontsCacheForTesting = clearLocalFontsCache;
function queryAllSystemFontsOnce(): Promise<LocalFontData[]> {
if (queryPromise) return queryPromise;
queryPromise = (async () => {
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as {
queryLocalFonts: () => Promise<LocalFontData[]>;
}).queryLocalFonts;
const fonts = await queryLocalFonts();
// A desktop OS always has fonts. Chromium can still resolve the
// API with an empty list when access is temporarily unavailable;
// do not treat that as authoritative or cache it for the session.
if (fonts.length === 0) {
queryPromise = null;
return [];
}
const familyNamesByLower = new Map<string, string>();
for (const font of fonts) {
const family = font.family.trim();
if (!family) continue;
const normalized = family.toLowerCase();
if (!familyNamesByLower.has(normalized)) {
familyNamesByLower.set(normalized, family);
}
}
allSystemFamilyNamesCache = [...familyNamesByLower.values()].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' }),
);
allSystemFamiliesCache = new Set(familyNamesByLower.keys());
return fonts;
} catch (error) {
// Don't sticky-cache a transient failure (e.g. LFA permission
// not ready yet at app boot, AbortError, etc.). Clearing the
// module-level promise lets the very next caller retry the
// API. Successful calls keep their cached promise as before,
// so this only retries when something actually went wrong.
console.warn('Failed to query local fonts:', error);
queryPromise = null;
return [];
}
})();
return queryPromise;
}
/**
* Returns the case-insensitive set of every font family installed on the
* system, as reported by the Local Font Access API. Used by
* fontAvailability.ts to decide which built-in font choices to show in
* the dropdown.
*
* Returns null when the API is unavailable or permission has been
* denied — callers should treat that as "no authoritative data" and
* fall back to canvas-width detection.
*/
export async function getAllSystemFontFamilies(): Promise<Set<string> | null> {
if (allSystemFamiliesCache) return allSystemFamiliesCache;
await queryAllSystemFontsOnce();
return allSystemFamiliesCache;
}
/**
* Returns installed font family names with display casing preserved.
* Families are trimmed, deduplicated case-insensitively, and sorted for
* stable searchable pickers.
*/
export async function getAllSystemFontFamilyNames(): Promise<string[] | null> {
if (allSystemFamilyNamesCache) return allSystemFamilyNamesCache;
await queryAllSystemFontsOnce();
return allSystemFamilyNamesCache;
}
/**
* Queries local monospace fonts from the system using the Font Access API.
* Returns an empty array if the API is not available or permission is denied.
*/
export async function getMonospaceFonts(): Promise<TerminalFont[]> {
const fonts = await queryAllSystemFontsOnce();
if (fonts.length === 0) return [];
// Filter monospace fonts using robust word boundary matching
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
const uniqueFamilies = new Set<string>();
const dedupedFonts = monoFonts.filter(f => {
const key = f.family.toLowerCase();
if (uniqueFamilies.has(key)) return false;
uniqueFamilies.add(key);
return true;
});
// Raw Latin family only; CJK fallback is composed at runtime by
// composeFontFamilyStack() in cjkFonts.ts.
return dedupedFonts.map(f => {
const quoted = /\s/.test(f.family) ? `"${f.family}"` : f.family;
return {
id: f.family,
name: f.family,
family: `${quoted}, monospace`,
description: `Local font: ${f.family}`,
category: 'monospace' as const,
};
});
}

65
lib/localShell.cjs Normal file
View File

@@ -0,0 +1,65 @@
"use strict";
const localShellRules = require("./localShellRules.json");
const POWERSHELL_SHELLS = new Set(localShellRules.powershellShells);
const CMD_SHELLS = new Set(localShellRules.cmdShells);
const FISH_SHELLS = new Set(localShellRules.fishShells);
const POSIX_SHELLS = new Set(localShellRules.posixShells);
const WSL_SHELLS = new Set(localShellRules.wslShells);
function getExecutableBaseName(filePath) {
const normalized = String(filePath || "").trim();
if (!normalized) return "";
const parts = normalized.split(/[\\/]/);
return (parts[parts.length - 1] || "").toLowerCase();
}
function detectLocalOs(platformLike) {
const platform = String(platformLike || "").toLowerCase();
if (platform.includes("mac")) return "macos";
if (platform.includes("win")) return "windows";
if (platform.includes("darwin")) return "macos";
return "linux";
}
/**
* True when `filePath` points inside `%LOCALAPPDATA%\Microsoft\WindowsApps`,
* i.e. it is a Windows App Execution Alias (MSIX/Store install stub).
*
* Aliases are zero-byte reparse points: `fs.statSync()` fails with EACCES and
* `fs.existsSync()` returns false, yet spawning the alias path still launches
* the packaged application. Consumers must not use existence checks to reject
* these paths. Deliberately free of `node:path` — this module is also bundled
* for the renderer.
*/
function isWindowsAppExecutionAliasPath(filePath) {
if (!filePath || typeof filePath !== "string") return false;
// Read via globalThis so renderer bundles (where `process` is not a global)
// stay valid; alias detection is a main-process concern and safely
// returns false when the env is unavailable.
const localAppData = globalThis.process?.env?.LOCALAPPDATA;
if (!localAppData) return false;
const normalize = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/").toLowerCase();
const aliasDir = `${normalize(localAppData)}/microsoft/windowsapps/`;
return normalize(filePath).startsWith(aliasDir);
}
function classifyLocalShellType(shellPath, platformLike) {
const shellName = getExecutableBaseName(shellPath);
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
if (CMD_SHELLS.has(shellName)) return "cmd";
if (FISH_SHELLS.has(shellName)) return "fish";
if (POSIX_SHELLS.has(shellName)) return "posix";
if (WSL_SHELLS.has(shellName)) return "posix";
if (!shellName) {
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
}
return "unknown";
}
module.exports = {
classifyLocalShellType,
detectLocalOs,
isWindowsAppExecutionAliasPath,
};

37
lib/localShell.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { createRequire } from "node:module";
import assert from "node:assert/strict";
import test from "node:test";
import { classifyLocalShellType, detectLocalOs } from "./localShell";
const require = createRequire(import.meta.url);
const cjsLocalShell = require("./localShell.cjs") as {
classifyLocalShellType: typeof classifyLocalShellType;
detectLocalOs: typeof detectLocalOs;
};
test("local shell classification is shared between renderer and CommonJS bridge", () => {
const cases: Array<[string | undefined, string | undefined, ReturnType<typeof classifyLocalShellType>]> = [
["/bin/zsh", "MacIntel", "posix"],
["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "Win32", "powershell"],
["C:\\Windows\\System32\\cmd.exe", "Win32", "cmd"],
["C:\\Windows\\System32\\wsl.exe", "Win32", "posix"],
["C:\\msys64\\usr\\bin\\bash.exe", "Win32", "posix"],
["fish", "linux", "fish"],
["", "Win32", "powershell"],
[undefined, "MacIntel", "posix"],
["custom-shell", "linux", "unknown"],
];
for (const [shellPath, platform, expected] of cases) {
assert.equal(classifyLocalShellType(shellPath, platform), expected);
assert.equal(cjsLocalShell.classifyLocalShellType(shellPath, platform), expected);
}
});
test("local OS detection is shared between renderer and CommonJS bridge", () => {
assert.equal(detectLocalOs("MacIntel"), "macos");
assert.equal(cjsLocalShell.detectLocalOs("MacIntel"), "macos");
assert.equal(detectLocalOs("Win32"), "windows");
assert.equal(cjsLocalShell.detectLocalOs("Win32"), "windows");
});

41
lib/localShell.ts Normal file
View File

@@ -0,0 +1,41 @@
import localShellRules from "./localShellRules.json";
export type LocalShellType = "posix" | "fish" | "powershell" | "cmd" | "unknown";
export type LocalOs = "linux" | "macos" | "windows";
const POWERSHELL_SHELLS = new Set(localShellRules.powershellShells);
const CMD_SHELLS = new Set(localShellRules.cmdShells);
const FISH_SHELLS = new Set(localShellRules.fishShells);
const POSIX_SHELLS = new Set(localShellRules.posixShells);
const WSL_SHELLS = new Set(localShellRules.wslShells);
function getExecutableBaseName(filePath: string | undefined) {
const normalized = String(filePath || "").trim();
if (!normalized) return "";
const parts = normalized.split(/[\\/]/);
return (parts[parts.length - 1] || "").toLowerCase();
}
export function detectLocalOs(platformLike?: string): LocalOs {
const platform = String(platformLike || "").toLowerCase();
if (platform.includes("mac")) return "macos";
if (platform.includes("win")) return "windows";
if (platform.includes("darwin")) return "macos";
return "linux";
}
export function classifyLocalShellType(
shellPath: string | undefined,
platformLike?: string,
): LocalShellType {
const shellName = getExecutableBaseName(shellPath);
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
if (CMD_SHELLS.has(shellName)) return "cmd";
if (FISH_SHELLS.has(shellName)) return "fish";
if (POSIX_SHELLS.has(shellName)) return "posix";
if (WSL_SHELLS.has(shellName)) return "posix";
if (!shellName) {
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
}
return "unknown";
}

7
lib/localShellRules.json Normal file
View File

@@ -0,0 +1,7 @@
{
"powershellShells": ["powershell", "powershell.exe", "pwsh", "pwsh.exe"],
"cmdShells": ["cmd", "cmd.exe"],
"fishShells": ["fish"],
"posixShells": ["sh", "bash", "zsh", "ksh", "dash", "ash", "bash.exe"],
"wslShells": ["wsl", "wsl.exe"]
}

24
lib/logger.ts Normal file
View File

@@ -0,0 +1,24 @@
type LogArgs = unknown[];
const isDev =
typeof import.meta !== "undefined" &&
typeof import.meta.env !== "undefined" &&
!!import.meta.env.DEV;
export const logger = {
debug: (...args: LogArgs) => {
if (!isDev) return;
console.debug(...args);
},
info: (...args: LogArgs) => {
if (!isDev) return;
console.info(...args);
},
warn: (...args: LogArgs) => {
console.warn(...args);
},
error: (...args: LogArgs) => {
console.error(...args);
},
};

View File

@@ -0,0 +1,815 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadLocalFoldersProgressively } from "./progressiveFolderUpload.ts";
import { UploadController } from "./uploadController.ts";
import type { LocalTreeListEntry } from "./sftpFileUtils.ts";
test("progressive folder upload starts file transfers before the tree walk finishes", async () => {
const events: string[] = [];
let releaseSecondBatch: (() => void) | null = null;
const secondBatchGate = new Promise<void>((resolve) => {
releaseSecondBatch = resolve;
});
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs",
relativePath: "docs",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 3,
lastModified: 1,
},
]);
events.push("batch1");
// First file should be uploadable while we still "discover" more.
await secondBatchGate;
options.onEntries?.([
{
localPath: "/tmp/docs/b.txt",
relativePath: "docs/b.txt",
type: "file",
size: 4,
lastModified: 2,
},
]);
events.push("batch2");
return [];
};
const transferred: string[] = [];
const controller = new UploadController();
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
events.push(`upload:${payload.sourcePath}`);
if (payload.sourcePath.endsWith("a.txt")) {
// Let the second discovery batch proceed only after the first upload started.
releaseSecondBatch?.();
}
return { transferId: payload.transferId };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => events.push(`created:${task.fileName}`),
onTaskCompleted: (taskId) => events.push(`completed:${taskId.slice(0, 8)}`),
onTaskProgress: (taskId, progress) => {
events.push(`progress:${progress.transferred}/${progress.total}:${progress.phase ?? ""}`);
},
},
},
controller,
);
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
assert.deepEqual(transferred, ["/tmp/docs/a.txt", "/tmp/docs/b.txt"]);
// First upload must happen before the second discovery batch finishes.
const uploadA = events.indexOf("upload:/tmp/docs/a.txt");
const batch2 = events.indexOf("batch2");
assert.ok(uploadA >= 0 && batch2 >= 0, "expected both upload and batch events");
assert.ok(uploadA < batch2, "first file must upload while scan still running");
});
test("progressive folder upload honors an explicit transfer concurrency above the default", async () => {
const requestedConcurrency = 16;
const entries: LocalTreeListEntry[] = Array.from(
{ length: requestedConcurrency + 2 },
(_, index) => ({
localPath: `/tmp/docs/file-${index}.txt`,
relativePath: `docs/file-${index}.txt`,
type: "file",
size: 1,
lastModified: index,
}),
);
const started: string[] = [];
let releaseAll!: () => void;
const gate = new Promise<void>((resolve) => {
releaseAll = resolve;
});
const config = {
targetPath: "/remote",
sftpId: "sftp-1",
fileTransferConcurrency: requestedConcurrency,
isLocal: false,
joinPath: (base: string, name: string) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload: { sourcePath: string; transferId: string }) => {
started.push(payload.sourcePath);
await gate;
return { transferId: payload.transferId };
},
},
listLocalTree: async (
_path: string,
options: { onEntries?: (batch: LocalTreeListEntry[]) => void },
) => {
options.onEntries?.(entries);
return [];
},
};
const uploading = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
config,
new UploadController(),
);
await new Promise((resolve) => setImmediate(resolve));
const initiallyStarted = started.length;
releaseAll();
await uploading;
assert.equal(initiallyStarted, requestedConcurrency);
});
test("progressive folder upload enqueues nested subdirectory files", async () => {
const transferred: string[] = [];
const mkdirs: string[] = [];
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs",
relativePath: "docs",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
options.onEntries?.([
{
localPath: "/tmp/docs/nested",
relativePath: "docs/nested",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/nested/deep",
relativePath: "docs/nested/deep",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/docs/nested/deep/b.txt",
relativePath: "docs/nested/deep/b.txt",
type: "file",
size: 2,
lastModified: 2,
},
]);
return [];
};
const results = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async (_id, dirPath) => {
mkdirs.push(dirPath);
},
startStreamTransfer: async (payload) => {
transferred.push(payload.targetPath);
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
assert.equal(results.filter((row) => row.success).length, 2);
assert.deepEqual(transferred.sort(), [
"/remote/docs/a.txt",
"/remote/docs/nested/deep/b.txt",
].sort());
assert.ok(mkdirs.includes("/remote/docs/nested"));
assert.ok(mkdirs.includes("/remote/docs/nested/deep"));
});
test("progressive folder upload stops enqueueing children after cancel", async () => {
const controller = new UploadController();
const createdChildren: string[] = [];
let entriesCb: ((entries: LocalTreeListEntry[]) => void) | null = null;
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
},
) => {
entriesCb = options.onEntries ?? null;
options.onEntries?.([
{
localPath: "/tmp/big/a.txt",
relativePath: "big/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
// Wait until cancelled.
await new Promise<void>((resolve) => {
if (options.abortSignal?.aborted) {
resolve();
return;
}
options.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
return [];
};
const abort = new AbortController();
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "big", localPath: "/tmp/big" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
abortSignal: abort.signal,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
// Cancel as soon as the first child begins.
await controller.cancel();
abort.abort();
// Push more discovered files after cancel — workers must not create more children.
entriesCb?.([
{
localPath: "/tmp/big/b.txt",
relativePath: "big/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
{
localPath: "/tmp/big/c.txt",
relativePath: "big/c.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
return { transferId: payload.transferId, cancelled: true };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => {
if (!task.isDirectory) createdChildren.push(task.fileName);
},
},
},
controller,
);
await uploadPromise;
// At most the in-flight child should have been created.
assert.ok(createdChildren.length <= 1, `expected no post-cancel flood, got ${createdChildren.join(",")}`);
});
test("progressive folder upload stops enqueueing children while soft-paused", async () => {
const createdChildren: string[] = [];
const transferred: string[] = [];
let paused = true;
let releasePause!: () => void;
const pauseGate = new Promise<void>((resolve) => {
releasePause = resolve;
});
let releaseMoreFiles!: () => void;
const moreFilesGate = new Promise<void>((resolve) => {
releaseMoreFiles = resolve;
});
const waitWhilePaused = async () => {
while (paused) {
await pauseGate;
}
};
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
options.onEntries?.([
{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
// Stay in the walk until the test unblocks the second batch (after pause assert).
await moreFilesGate;
options.onEntries?.([
{
localPath: "/tmp/docs/b.txt",
relativePath: "docs/b.txt",
type: "file",
size: 1,
lastModified: 2,
},
{
localPath: "/tmp/docs/c.txt",
relativePath: "docs/c.txt",
type: "file",
size: 1,
lastModified: 3,
},
]);
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
waitWhilePaused,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree,
callbacks: {
onTaskCreated: (task) => {
if (!task.isDirectory) createdChildren.push(task.fileName);
},
},
},
);
// Give workers a turn; soft-pause must block child creation entirely.
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(createdChildren, [], "no children while paused");
assert.deepEqual(transferred, [], "no transfers while paused");
paused = false;
releasePause();
// Let first file create/upload, then release the rest of the tree.
await new Promise((resolve) => setTimeout(resolve, 40));
releaseMoreFiles();
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 3);
assert.deepEqual(
createdChildren.sort(),
["docs/a.txt", "docs/b.txt", "docs/c.txt"].sort(),
);
assert.equal(transferred.length, 3);
});
test("progressive multi-root pause does not HOL-block an unpaused sibling root", async () => {
const transferred: string[] = [];
const pausedParents = new Set<string>(["parent-a"]);
const parentIds = new Map([
["folderA", "parent-a"],
["folderB", "parent-b"],
]);
const waitWhilePaused = async (parentId: string) => {
while (pausedParents.has(parentId)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
const listLocalTree = async (
localPath: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
if (localPath.endsWith("folderA")) {
options.onEntries?.([
{
localPath: "/tmp/folderA/a.txt",
relativePath: "folderA/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
} else {
options.onEntries?.([
{
localPath: "/tmp/folderB/b.txt",
relativePath: "folderB/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
}
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[
{ name: "folderA", localPath: "/tmp/folderA" },
{ name: "folderB", localPath: "/tmp/folderB" },
],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
parentTaskIds: parentIds,
waitWhilePaused,
isPaused: (parentId) => pausedParents.has(parentId),
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
// Unpause A only after B has uploaded — proves HOL skip works.
if (payload.sourcePath.includes("folderB")) {
pausedParents.delete("parent-a");
}
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
assert.ok(transferred.some((p) => p.includes("folderB")), "unpaused root must transfer");
assert.ok(transferred.some((p) => p.includes("folderA")), "paused root resumes after unlatch");
// folderB must not wait behind folderA indefinitely — B completes first.
assert.ok(
transferred.indexOf(transferred.find((p) => p.includes("folderB"))!)
< transferred.indexOf(transferred.find((p) => p.includes("folderA"))!),
`expected B before A, got ${transferred.join(",")}`,
);
});
test("progressive backpressure wakes every parked enqueue waiter", async () => {
// Discovery floods the queue while workers hold transfers open so the queue
// stays above the high-water mark. Multiple enqueueBatch waiters must all
// be released when workers drain past the low-water mark.
const transferred: string[] = [];
let releaseTransfers!: () => void;
const transferGate = new Promise<void>((resolve) => {
releaseTransfers = resolve;
});
let started = 0;
const listLocalTree = async (
_path: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
const batches = Array.from({ length: 8 }, (_, batch) => (
Array.from({ length: 300 }, (__, i) => ({
localPath: `/tmp/docs/f-${batch}-${i}.txt`,
relativePath: `docs/f-${batch}-${i}.txt`,
type: "file" as const,
size: 1,
lastModified: 1,
}))
));
// Overlapping handlers so several park on waitIfQueueHigh together.
await Promise.all(batches.map((batch) => Promise.resolve().then(() => options.onEntries?.(batch))));
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
started += 1;
transferred.push(payload.sourcePath);
await transferGate;
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
// Let discovery fill past high-water (2000) while 2 workers hold the gate.
await new Promise((resolve) => setTimeout(resolve, 80));
assert.ok(started >= 1, "workers should have started");
releaseTransfers();
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 8 * 300);
assert.equal(transferred.length, 8 * 300);
});
test("progressive root conflict skip does not overwrite an existing remote folder", async () => {
const transferred: string[] = [];
const result = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "skip",
bridge: {
mkdirSftp: async () => {},
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1 }),
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
throw new Error("scan must not run after skip");
},
},
);
assert.equal(transferred.length, 0);
assert.equal(result[0]?.cancelled, true);
});
test("progressive root conflict check fails closed when destination access is denied", async () => {
let conflictPrompts = 0;
let scans = 0;
let transfers = 0;
await assert.rejects(
() => uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
conflictPrompts += 1;
return "skip";
},
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error("Permission denied") as Error & { code: string };
error.code = "EACCES";
throw error;
},
startStreamTransfer: async (payload) => {
transfers += 1;
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
scans += 1;
return [];
},
},
),
/Permission denied/,
);
assert.equal(conflictPrompts, 0, "unknown destination state must not be treated as a conflict");
assert.equal(scans, 0, "folder scanning must not start after a failed destination check");
assert.equal(transfers, 0, "upload must not start when destination safety is unknown");
});
test("progressive root conflict check treats explicit ENOENT as an absent destination", async () => {
const transferred: string[] = [];
let followedStats = 0;
const results = await uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
throw new Error("absent destination must not prompt for conflict");
},
bridge: {
mkdirSftp: async () => {},
statSftp: async () => {
followedStats += 1;
return { type: "directory", size: 0, lastModified: 1 };
},
lstatSftp: async () => {
const error = new Error("No such file") as Error & { code: string };
error.code = "ENOENT";
throw error;
},
startStreamTransfer: async (payload) => {
transferred.push(payload.targetPath);
return { transferId: payload.transferId };
},
},
listLocalTree: async (_path, options) => {
options.onEntries?.([{
localPath: "/tmp/docs/a.txt",
relativePath: "docs/a.txt",
type: "file",
size: 1,
lastModified: 1,
}]);
return [];
},
},
);
assert.equal(followedStats, 0, "destination checks should prefer no-follow lstat");
assert.deepEqual(transferred, ["/remote/docs/a.txt"]);
assert.equal(results[0]?.success, true);
});
test("progressive root replace carries the inspected type into the guarded delete", async () => {
let currentType: "file" | "directory" = "directory";
let scans = 0;
let transfers = 0;
await assert.rejects(
() => uploadLocalFoldersProgressively(
[{ name: "docs", localPath: "/tmp/docs" }],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
currentType = "file";
return "replace";
},
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => ({ type: "directory", size: 0, lastModified: 1 }),
deleteSftp: async (_sftpId, _path, expectedType) => {
assert.equal(expectedType, "directory");
if (currentType !== expectedType) {
throw new Error(`Expected ${expectedType}, found ${currentType}`);
}
},
startStreamTransfer: async (payload) => {
transfers += 1;
return { transferId: payload.transferId };
},
},
listLocalTree: async () => {
scans += 1;
return [];
},
},
),
/Expected directory, found file/,
);
assert.equal(scans, 0, "changed targets must abort before scanning starts");
assert.equal(transfers, 0, "changed targets must not be overwritten");
});
test("progressive multi-root resume of non-head parent unblocks while head stays paused", async () => {
const transferred: string[] = [];
// Both latched initially; head is always parent-a in FIFO order.
const pausedParents = new Set<string>(["parent-a", "parent-b"]);
const parentIds = new Map([
["folderA", "parent-a"],
["folderB", "parent-b"],
]);
const waiters = new Map<string, Array<() => void>>();
const waitWhilePaused = async (parentId: string) => {
while (pausedParents.has(parentId)) {
await new Promise<void>((resolve) => {
const list = waiters.get(parentId) ?? [];
list.push(resolve);
waiters.set(parentId, list);
});
}
};
const release = (parentId: string) => {
pausedParents.delete(parentId);
for (const resolve of waiters.get(parentId) ?? []) resolve();
waiters.delete(parentId);
};
const listLocalTree = async (
localPath: string,
options: {
onEntries?: (entries: LocalTreeListEntry[]) => void;
},
) => {
if (localPath.endsWith("folderA")) {
options.onEntries?.([
{
localPath: "/tmp/folderA/a.txt",
relativePath: "folderA/a.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
} else {
options.onEntries?.([
{
localPath: "/tmp/folderB/b.txt",
relativePath: "folderB/b.txt",
type: "file",
size: 1,
lastModified: 1,
},
]);
}
return [];
};
const uploadPromise = uploadLocalFoldersProgressively(
[
{ name: "folderA", localPath: "/tmp/folderA" },
{ name: "folderB", localPath: "/tmp/folderB" },
],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
joinPath: (base, name) => `${base}/${name}`,
parentTaskIds: parentIds,
waitWhilePaused,
isPaused: (parentId) => pausedParents.has(parentId),
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transferred.push(payload.sourcePath);
return { transferId: payload.transferId };
},
},
listLocalTree,
},
);
// Let both jobs queue and workers park on all-paused race.
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(transferred, []);
// Resume only non-head parent B while A stays paused.
release("parent-b");
await new Promise((resolve) => setTimeout(resolve, 40));
assert.ok(
transferred.some((p) => p.includes("folderB")),
`B must transfer while A paused, got ${transferred.join(",")}`,
);
assert.equal(
transferred.some((p) => p.includes("folderA")),
false,
"A must stay blocked",
);
release("parent-a");
const results = await uploadPromise;
assert.equal(results.filter((row) => row.success).length, 2);
});

View File

@@ -0,0 +1,689 @@
/**
* Progressive local-folder upload: stream discovery batches from listLocalTree
* and upload files as they arrive (edge-scan / edge-transfer).
*/
import type { DropEntry } from "./sftpFileUtils";
import { localTreeToDropEntries, type LocalTreeListEntry } from "./sftpFileUtils";
import type { UploadBridge, UploadCallbacks, UploadResult } from "./uploadService.types";
import type { UploadController } from "./uploadController";
import {
canReplaceSftpConflict,
describeSftpExistingKind,
describeSftpIncomingKind,
getSftpConflictTypeKey,
} from "../domain/sftpConflict";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "../domain/sftpTransferConcurrency";
import { isMissingStatError } from "../domain/sftpStatError";
const formatUploadError = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
/** Keep progressive worker admission aligned with the user-visible transfer limit. */
export const DEFAULT_PROGRESSIVE_FOLDER_UPLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
export const resolveProgressiveFolderUploadConcurrency = (
savedValue: number | null | undefined,
): number => resolveSftpTransferConcurrency(() => savedValue);
/** Pause discovery when the upload queue grows this large (memory backpressure). */
const QUEUE_HIGH_WATER = 2_000;
/** Resume discovery once the queue drains to this size. */
const QUEUE_LOW_WATER = 500;
export type ProgressiveLocalRoot = {
name: string;
localPath: string;
};
export type ListLocalTreeStreaming = (
path: string,
options: {
onProgress?: (progress: { fileCount: number; directoryCount: number; entryCount: number }) => void;
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
},
) => Promise<LocalTreeListEntry[]>;
export type ProgressiveConflictAction = "stop" | "skip" | "replace" | "duplicate" | "merge";
export type ProgressiveFolderUploadConfig = {
targetPath: string;
sftpId: string | null;
targetHostId?: string;
/** Maximum number of file transfers this upload may start at once. */
fileTransferConcurrency?: number;
isLocal: boolean;
bridge: UploadBridge;
joinPath: (base: string, name: string) => string;
callbacks?: UploadCallbacks;
listLocalTree: ListLocalTreeStreaming;
/** Optional pre-created parent task ids (e.g. scanning task id for single root). */
parentTaskIds?: Map<string, string>;
abortSignal?: AbortSignal;
/**
* Soft-pause gate (process-global latch). Must resolve only after the parent
* folder is resumed. Used before discovery enqueue and before creating child
* UI rows so Pause does not keep filling the transfer queue.
*/
waitWhilePaused?: (parentTaskId: string) => Promise<void>;
/** Sync probe so multi-root queues can skip latched parents (no HOL block). */
isPaused?: (parentTaskId: string) => boolean;
/**
* Root-level conflict dialog (same contract as uploadEntries). Progressive
* path must not overwrite existing remote folders without confirmation.
*/
resolveConflict?: (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: "file" | "directory" | "symlink";
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}) => Promise<ProgressiveConflictAction>;
};
function entryToDrop(entry: LocalTreeListEntry): DropEntry {
return localTreeToDropEntries([entry])[0];
}
/**
* Walk each local root with streaming batches and upload files concurrently
* while discovery continues.
*/
export async function uploadLocalFoldersProgressively(
roots: ProgressiveLocalRoot[],
config: ProgressiveFolderUploadConfig,
controller?: UploadController,
): Promise<UploadResult[]> {
const {
targetPath,
sftpId,
targetHostId,
fileTransferConcurrency,
isLocal,
bridge,
joinPath,
callbacks,
listLocalTree,
parentTaskIds,
abortSignal,
waitWhilePaused,
isPaused,
resolveConflict,
} = config;
if (roots.length === 0) return [];
if (!isLocal && !sftpId) {
throw new Error("No SFTP session for progressive folder upload");
}
const isStopped = () => !!(controller?.isCancelled() || abortSignal?.aborted);
/** Block while the folder soft-pause latch is held; re-check cancel after wake. */
const awaitUnpaused = async (parentTaskId: string): Promise<boolean> => {
if (!waitWhilePaused) return !isStopped();
while (!isStopped()) {
await waitWhilePaused(parentTaskId);
// waitWhilePaused may resolve spuriously if the latch was already free;
// always re-check stop intent after any await.
if (isStopped()) return false;
return true;
}
return false;
};
const results: UploadResult[] = [];
const createdDirs = new Set<string>();
const failedDirs = new Map<string, string>();
const parentIds = new Map<string, string>();
const parentStats = new Map<string, {
discovered: number;
completed: number;
failed: number;
dirFailures: number;
}>();
/** Source root name -> destination root segment after conflict resolution. */
const destRootNameBySource = new Map<string, string>();
const statTarget = async (path: string) => {
try {
// Prefer no-follow lstat so Replace cannot write through a symlink.
if (isLocal) return await (bridge.lstatLocal ?? bridge.statLocal)?.(path) ?? null;
if (sftpId) return await (bridge.lstatSftp ?? bridge.statSftp)?.(sftpId, path) ?? null;
} catch (error) {
if (isMissingStatError(error)) return null;
// Unknown target state must fail closed rather than bypass conflict handling.
throw error;
}
return null;
};
const deleteTarget = async (
path: string,
expectedType?: "file" | "directory" | "symlink",
) => {
if (isLocal) await bridge.deleteLocalFile?.(path, expectedType);
else if (sftpId) await bridge.deleteSftp?.(sftpId, path, expectedType);
};
const getDuplicateName = async (name: string) => {
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const candidate = `${name}${suffix}`;
const existing = await statTarget(joinPath(targetPath, candidate));
if (!existing) return candidate;
}
return `${name} (copy ${Date.now()})`;
};
// Root-level conflict preflight — match uploadEntries so progressive drops
// cannot overwrite remote content without Skip/Replace/Duplicate/Merge.
let activeRoots = [...roots];
if (resolveConflict) {
const existingByRoot = await Promise.all(activeRoots.map(async (root) => ({
root,
existing: await statTarget(joinPath(targetPath, root.name)),
})));
const conflictCounts = new Map<string, number>();
for (const { existing } of existingByRoot) {
if (!existing) continue;
const key = getSftpConflictTypeKey(true, existing.type);
conflictCounts.set(key, (conflictCounts.get(key) ?? 0) + 1);
}
const kept: ProgressiveLocalRoot[] = [];
for (const { root, existing } of existingByRoot) {
if (isStopped()) break;
if (!existing) {
destRootNameBySource.set(root.name, root.name);
kept.push(root);
continue;
}
const conflictKey = getSftpConflictTypeKey(true, existing.type);
const action = await resolveConflict({
fileName: root.name,
targetPath: joinPath(targetPath, root.name),
isDirectory: true,
existingType: existing.type,
existingSize: existing.size,
// Progressive has not walked yet; byte total is unknown.
newSize: 0,
existingModified: existing.lastModified,
newModified: Date.now(),
applyToAllCount: conflictCounts.get(conflictKey) ?? 1,
});
if (action === "stop") {
await controller?.cancel();
return [{ fileName: root.name, success: false, cancelled: true }, ...results];
}
if (action === "skip") {
results.push({ fileName: root.name, success: false, cancelled: true });
const scanningId = parentTaskIds?.get(root.name);
if (scanningId) callbacks?.onTaskCancelled?.(scanningId);
continue;
}
if (action === "replace") {
if (!canReplaceSftpConflict(true, existing.type)) {
results.push({
fileName: root.name,
success: false,
error: `Cannot replace existing ${describeSftpExistingKind(existing.type)} with ${describeSftpIncomingKind(true)}: ${joinPath(targetPath, root.name)}`,
});
const scanningId = parentTaskIds?.get(root.name);
if (scanningId) {
callbacks?.onTaskFailed?.(
scanningId,
`Cannot replace existing ${describeSftpExistingKind(existing.type)}`,
);
}
continue;
}
await deleteTarget(joinPath(targetPath, root.name), existing.type);
destRootNameBySource.set(root.name, root.name);
kept.push(root);
continue;
}
if (action === "duplicate") {
const duplicateName = await getDuplicateName(root.name);
destRootNameBySource.set(root.name, duplicateName);
kept.push(root);
continue;
}
if (action === "merge" && !(existing.type === "directory")) {
results.push({
fileName: root.name,
success: false,
error: `Cannot merge existing ${describeSftpExistingKind(existing.type)} with ${describeSftpIncomingKind(true)}: ${joinPath(targetPath, root.name)}`,
});
const scanningId = parentTaskIds?.get(root.name);
if (scanningId) {
callbacks?.onTaskFailed?.(scanningId, `Cannot merge existing ${describeSftpExistingKind(existing.type)}`);
}
continue;
}
destRootNameBySource.set(root.name, root.name);
kept.push(root);
}
activeRoots = kept;
} else {
for (const root of activeRoots) destRootNameBySource.set(root.name, root.name);
}
if (activeRoots.length === 0) return results;
for (const root of activeRoots) {
const id = parentTaskIds?.get(root.name) ?? crypto.randomUUID();
parentIds.set(root.name, id);
parentStats.set(id, { discovered: 0, completed: 0, failed: 0, dirFailures: 0 });
const destName = destRootNameBySource.get(root.name) ?? root.name;
// Skip create if caller already opened a scanning row with this id.
if (!parentTaskIds?.has(root.name)) {
callbacks?.onTaskCreated?.({
id,
fileName: destName,
displayName: destName,
isDirectory: true,
progressMode: "files",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
fileCount: 0,
completedCount: 0,
sourcePath: root.localPath,
});
} else {
// Promote scanning row into live file-count progress.
callbacks?.onTaskProgress?.(id, {
transferred: 0,
total: 0,
speed: 0,
percent: 0,
phase: "scanning",
});
}
}
const ensureDirectory = async (dirPath: string): Promise<void> => {
if (createdDirs.has(dirPath)) return;
if (failedDirs.has(dirPath)) {
throw new Error(failedDirs.get(dirPath) || "Directory creation failed");
}
try {
if (isLocal) {
await bridge.mkdirLocal?.(dirPath);
} else if (sftpId) {
await bridge.mkdirSftp(sftpId, dirPath);
}
createdDirs.add(dirPath);
} catch (error) {
const message = formatUploadError(error);
// Concurrent workers / merge into existing remote dirs race here.
if (/exist|EEXIST|file exists|already/i.test(message)) {
createdDirs.add(dirPath);
return;
}
failedDirs.set(dirPath, message);
throw error;
}
};
const remapRelativePath = (sourceRootName: string, relativePath: string): string => {
const dest = destRootNameBySource.get(sourceRootName) ?? sourceRootName;
if (dest === sourceRootName) return relativePath;
if (relativePath === sourceRootName) return dest;
if (relativePath.startsWith(`${sourceRootName}/`)) {
return `${dest}/${relativePath.slice(sourceRootName.length + 1)}`;
}
return relativePath;
};
const ensureParentsForFile = async (relativePath: string): Promise<void> => {
const parts = relativePath.replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length <= 1) return;
let cursor = targetPath;
for (let i = 0; i < parts.length - 1; i++) {
cursor = joinPath(cursor, parts[i]);
await ensureDirectory(cursor);
}
};
type FileJob = { entry: DropEntry; parentId: string; rootName: string };
const fileQueue: FileJob[] = [];
let scanDone = false;
/** In-flight onEntries handlers (may await soft-pause before queueing). */
let pendingEnqueues = 0;
let scanError: unknown;
let wakeWaiters: Array<() => void> = [];
const wake = () => {
const waiters = wakeWaiters;
wakeWaiters = [];
for (const resolve of waiters) resolve();
};
const waitForWork = () => new Promise<void>((resolve) => {
wakeWaiters.push(resolve);
});
const discoverySettled = () => scanDone && pendingEnqueues === 0;
// Multiple enqueueBatch handlers can park on backpressure at once. A single
// resolver would orphan older waiters and leave pendingEnqueues stuck forever.
let pauseScanWaiters: Array<() => void> = [];
const waitIfQueueHigh = async () => {
while (fileQueue.length >= QUEUE_HIGH_WATER && !isStopped()) {
await new Promise<void>((resolve) => {
pauseScanWaiters.push(resolve);
});
}
};
const maybeResumeScan = () => {
if (fileQueue.length > QUEUE_LOW_WATER || pauseScanWaiters.length === 0) return;
const waiters = pauseScanWaiters;
pauseScanWaiters = [];
for (const resolve of waiters) resolve();
};
const publishParentProgress = (parentId: string, phase?: "scanning" | "transferring") => {
const stats = parentStats.get(parentId);
if (!stats) return;
const total = Math.max(stats.discovered, stats.completed);
callbacks?.onTaskProgress?.(parentId, {
transferred: stats.completed,
total,
speed: 0,
percent: total > 0 ? (stats.completed / total) * 100 : 0,
phase: phase ?? (stats.completed > 0 ? "transferring" : "scanning"),
});
};
const enqueueBatch = async (rootName: string, batch: LocalTreeListEntry[]) => {
pendingEnqueues += 1;
try {
if (isStopped()) {
fileQueue.length = 0;
return;
}
const parentId = parentIds.get(rootName);
if (!parentId) return;
const stats = parentStats.get(parentId);
if (!stats) return;
// Soft-pause: do not grow the work queue or create remote dirs until resume.
// Otherwise Pause still looks "alive" as hundreds of pending children appear.
if (!(await awaitUnpaused(parentId))) {
fileQueue.length = 0;
return;
}
for (const row of batch) {
if (isStopped()) {
fileQueue.length = 0;
return;
}
// Re-check pause between entries so a mid-batch Pause freezes the rest.
if (waitWhilePaused && !(await awaitUnpaused(parentId))) {
fileQueue.length = 0;
return;
}
const drop = entryToDrop(row);
const remappedRelative = remapRelativePath(rootName, drop.relativePath);
const remappedDrop = remappedRelative === drop.relativePath
? drop
: { ...drop, relativePath: remappedRelative };
if (remappedDrop.isDirectory) {
// Create remote dirs early when we see them. Empty-directory failures
// must not be silently ignored (no later file will retry the mkdir).
try {
await ensureDirectory(joinPath(targetPath, remappedDrop.relativePath));
} catch (error) {
stats.dirFailures += 1;
results.push({
fileName: remappedDrop.relativePath,
success: false,
error: formatUploadError(error),
});
}
continue;
}
stats.discovered += 1;
fileQueue.push({ entry: remappedDrop, parentId, rootName });
}
publishParentProgress(parentId);
wake();
maybeResumeScan();
await waitIfQueueHigh();
} finally {
pendingEnqueues = Math.max(0, pendingEnqueues - 1);
// Workers may have seen an empty queue while we were still paused inside
// this handler — wake them once discovery work is actually settled.
wake();
}
};
const scanPromise = (async () => {
try {
for (const root of activeRoots) {
if (isStopped()) break;
await listLocalTree(root.localPath, {
abortSignal,
onProgress: () => {
// Counts are derived from entry batches so UI stays consistent.
},
onEntries: (batch) => {
void enqueueBatch(root.name, batch);
},
});
}
} catch (error) {
scanError = error;
} finally {
scanDone = true;
wake();
if (pauseScanWaiters.length > 0) {
const waiters = pauseScanWaiters;
pauseScanWaiters = [];
for (const resolve of waiters) resolve();
}
}
})();
const uploadSingle = async (job: FileJob): Promise<void> => {
if (isStopped()) return;
const { entry, parentId } = job;
const stats = parentStats.get(parentId);
if (!stats) return;
// Soft-pause and cancel both block before a child row is created. Creating
// the UI task first made Pause look broken: new "pending" children kept
// flooding the panel even though streams were soft-drained.
if (!(await awaitUnpaused(parentId))) return;
const entryTargetPath = joinPath(targetPath, entry.relativePath);
const childId = crypto.randomUUID();
const fileTotalBytes = entry.size ?? 0;
callbacks?.onTaskCreated?.({
id: childId,
fileName: entry.relativePath,
displayName: entry.relativePath,
isDirectory: false,
progressMode: "bytes",
parentTaskId: parentId,
totalBytes: fileTotalBytes,
transferredBytes: 0,
speed: 0,
fileCount: 1,
completedCount: 0,
sourcePath: entry.localPath,
});
if (isStopped()) {
callbacks?.onTaskCancelled?.(childId);
return;
}
// Pause may have been hit between create and stream open — wait again so we
// never start a new write under a paused parent.
if (!(await awaitUnpaused(parentId))) {
callbacks?.onTaskCancelled?.(childId);
return;
}
try {
await ensureParentsForFile(entry.relativePath);
const localFilePath = entry.localPath;
if (!localFilePath || !bridge.startStreamTransfer) {
throw new Error("A local file path is required for streaming SFTP upload");
}
controller?.addActiveTransfer(childId);
let streamResult: { error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer({
transferId: childId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: "local",
targetType: isLocal ? "local" : "sftp",
targetSftpId: isLocal ? undefined : sftpId ?? undefined,
targetHostId: isLocal ? undefined : targetHostId,
totalBytes: fileTotalBytes,
resumable: true,
checkpointBytes: 0,
});
} finally {
controller?.removeActiveTransfer(childId);
}
if (streamResult?.cancelled || streamResult?.error?.includes("cancelled")) {
callbacks?.onTaskCancelled?.(childId);
return;
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
results.push({ fileName: entry.relativePath, success: true });
stats.completed += 1;
callbacks?.onTaskCompleted?.(childId, fileTotalBytes);
publishParentProgress(parentId, "transferring");
} catch (error) {
if (controller?.isCancelled()) {
callbacks?.onTaskCancelled?.(childId);
return;
}
const message = formatUploadError(error);
results.push({ fileName: entry.relativePath, success: false, error: message });
stats.failed += 1;
stats.completed += 1;
callbacks?.onTaskFailed?.(childId, message);
publishParentProgress(parentId, "transferring");
} finally {
maybeResumeScan();
wake();
}
};
const uploadConcurrency = resolveProgressiveFolderUploadConcurrency(fileTransferConcurrency);
const workers = Array.from({ length: uploadConcurrency }, async () => {
while (true) {
if (isStopped()) {
fileQueue.length = 0;
return;
}
if (fileQueue.length === 0) {
// onEntries handlers may still be awaiting soft-pause before pushing
// jobs — do not treat scanDone alone as terminal.
if (discoverySettled()) return;
await waitForWork();
continue;
}
// Soft-pause is per-parent. Prefer an unpaused job so pausing parent A
// does not head-of-line block parent B on the shared FIFO.
if (fileQueue.length === 0) continue;
let pickIndex = 0;
if (isPaused) {
const freeIndex = fileQueue.findIndex((j) => !isPaused(j.parentId));
if (freeIndex < 0) {
// Every pending parent is latched. Race-wait on *all* of them so
// resuming a non-head parent does not stall behind a still-paused head.
const pausedParents = [...new Set(fileQueue.map((j) => j.parentId))];
let anyReleased = false;
await Promise.race(
pausedParents.map(async (parentId) => {
if (await awaitUnpaused(parentId)) anyReleased = true;
}),
);
if (!anyReleased || isStopped()) {
fileQueue.length = 0;
return;
}
continue;
}
pickIndex = freeIndex;
} else if (waitWhilePaused) {
if (!(await awaitUnpaused(fileQueue[0].parentId))) {
fileQueue.length = 0;
return;
}
}
if (isStopped()) {
fileQueue.length = 0;
return;
}
if (fileQueue.length === 0) continue;
const [job] = fileQueue.splice(pickIndex, 1);
if (!job) continue;
maybeResumeScan();
if (isStopped()) {
fileQueue.length = 0;
return;
}
await uploadSingle(job);
}
});
await Promise.all([scanPromise, ...workers]);
if (controller?.isCancelled()) {
for (const parentId of parentIds.values()) {
callbacks?.onTaskCancelled?.(parentId);
}
return [{ fileName: "", success: false, cancelled: true }, ...results];
}
if (scanError) {
for (const parentId of parentIds.values()) {
callbacks?.onTaskFailed?.(parentId, formatUploadError(scanError));
}
throw scanError;
}
for (const [parentId, stats] of parentStats) {
if (stats.failed > 0 || stats.dirFailures > 0) {
const parts: string[] = [];
if (stats.failed > 0) {
parts.push(
stats.failed === stats.discovered && stats.discovered > 0
? `All ${stats.failed} files failed`
: `${stats.failed} of ${stats.discovered} files failed`,
);
}
if (stats.dirFailures > 0) {
parts.push(
stats.dirFailures === 1
? "1 directory could not be created"
: `${stats.dirFailures} directories could not be created`,
);
}
callbacks?.onTaskFailed?.(parentId, parts.join("; "));
} else {
callbacks?.onTaskCompleted?.(parentId, stats.discovered);
}
}
return results;
}

59
lib/readTextFile.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readTextFile } from "./readTextFile";
test("readTextFile decodes UTF-8 text without BOM", async () => {
const file = new File(["hello"], "note.md", { type: "text/plain" });
assert.equal(await readTextFile(file), "hello");
});
test("readTextFile strips UTF-8 BOM", async () => {
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("hello")]);
const file = new File([bytes], "note.md", { type: "text/plain" });
assert.equal(await readTextFile(file), "hello");
});
test("readTextFile decodes UTF-16 LE with BOM", async () => {
const bytes = new Uint8Array([0xff, 0xfe, 0x68, 0x00, 0x69, 0x00]);
const file = new File([bytes], "note.md", { type: "text/plain" });
assert.equal(await readTextFile(file), "hi");
});
test("readTextFile decodes UTF-16 BE with BOM", async () => {
const bytes = new Uint8Array([0xfe, 0xff, 0x00, 0x68, 0x00, 0x69]);
const file = new File([bytes], "note.md", { type: "text/plain" });
assert.equal(await readTextFile(file), "hi");
});
test("readTextFile uses the fallback encoding for non-UTF-8 text without a BOM", async () => {
const bytes = new Uint8Array([
0x5b, 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x5d, 0x0a,
0xd6, 0xd0, 0xce, 0xc4, 0xb7, 0xfe, 0xce, 0xf1, 0xc6, 0xf7,
]);
const file = new File([bytes], "MobaXterm.ini", { type: "text/plain" });
assert.equal(
await readTextFile(file, { fallbackEncoding: "gb18030" }),
"[Bookmarks]\n中文服务器",
);
});
test("readTextFile keeps valid UTF-8 when a fallback encoding is configured", async () => {
const file = new File(["[Bookmarks]\n中文服务器"], "MobaXterm.ini", {
type: "text/plain",
});
assert.equal(
await readTextFile(file, { fallbackEncoding: "gb18030" }),
"[Bookmarks]\n中文服务器",
);
});
test("readTextFile honors an explicit encoding for ambiguous bytes", async () => {
const file = new File([new Uint8Array([0xc2, 0xa1])], "MobaXterm.ini", {
type: "text/plain",
});
assert.equal(await readTextFile(file, { encoding: "utf-8" }), "¡");
assert.equal(await readTextFile(file, { encoding: "gb18030" }), "隆");
});

48
lib/readTextFile.ts Normal file
View File

@@ -0,0 +1,48 @@
type ReadTextFileOptions = {
encoding?: string;
fallbackEncoding?: string;
};
export async function readTextFile(
file: File,
options: ReadTextFileOptions = {},
): Promise<string> {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
if (options.encoding) {
return new TextDecoder(options.encoding).decode(bytes);
}
let encoding: string = "utf-8";
let offset = 0;
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
encoding = "utf-16le";
offset = 2;
} else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
encoding = "utf-16be";
offset = 2;
} else if (
bytes.length >= 3 &&
bytes[0] === 0xef &&
bytes[1] === 0xbb &&
bytes[2] === 0xbf
) {
encoding = "utf-8";
offset = 3;
}
const content = bytes.slice(offset);
if (offset === 0 && options.fallbackEncoding) {
let utf8: string;
try {
utf8 = new TextDecoder("utf-8", { fatal: true }).decode(content);
} catch {
return new TextDecoder(options.fallbackEncoding).decode(content);
}
return utf8;
}
return new TextDecoder(encoding).decode(content);
}

19
lib/regexSafety.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Best-effort regex safety guard for user-provided patterns.
*
* Reject nested quantifier shapes such as `(a+)+`, `(a*)*`, `(a+){2,}`
* that are common catastrophic-backtracking sources.
*/
export type RegexSafetyReason = "nested_quantifier";
export type RegexSafetyCheckResult =
| { safe: true }
| { safe: false; reason: RegexSafetyReason };
export function checkRegexSafetyPattern(pattern: string): RegexSafetyCheckResult {
const nestedUnboundedQuantifier = /\((?:\?:)?[^)]*(?:\+|\*|\{\d+,\}|\{,\d+\})[^)]*\)(?:\+|\*|\{\d+,\}|\{,\d+\})/;
if (nestedUnboundedQuantifier.test(pattern)) {
return { safe: false, reason: "nested_quantifier" };
}
return { safe: true };
}

369
lib/searchMatcher.ts Normal file
View File

@@ -0,0 +1,369 @@
import { pinyin } from "pinyin-pro";
const SEARCH_SPLIT_REGEX = /[\s\p{Pd}_/\\|.,;:!?()[\]{}<>"'`~·]+/u;
const SEARCH_REMOVE_REGEX = /[\s\p{Pd}_/\\|.,;:!?()[\]{}<>"'`~·]+/gu;
const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u;
const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,;:!?()[\]{}<>"'`~·]/u;
const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu;
export const PINYIN_CACHE_MAX_ENTRIES = 16_384;
const PINYIN_CACHE = new Map<string, { full: string; initials: string }>();
const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/;
const HOST_PHASE_SCORE_BONUS = {
strict: 1_000_000,
loose: 100_000,
} as const;
const HOST_FIELD_SCORE = {
label: 500,
hostname: 380,
group: 280,
tag: 220,
} as const;
const HOST_METHOD_SCORE = {
literal: 50,
compact: 25,
pinyinFull: 12,
pinyinInitials: 8,
} as const;
function normalizeText(input: string): string {
return input.normalize("NFKC").replace(DASH_SEPARATOR_REGEX, "-").toLowerCase().trim();
}
function compactText(input: string): string {
return normalizeText(input).replace(SEARCH_REMOVE_REGEX, "");
}
function getPinyinVariants(sourceText: string): { full: string; initials: string } {
const cacheKey = normalizeText(sourceText);
const cached = PINYIN_CACHE.get(cacheKey);
if (cached) {
PINYIN_CACHE.delete(cacheKey);
PINYIN_CACHE.set(cacheKey, cached);
return cached;
}
let full = "";
let initials = "";
try {
full = compactText(
pinyin(sourceText, {
toneType: "none",
}),
);
initials = compactText(
pinyin(sourceText, {
pattern: "first",
toneType: "none",
}),
);
} catch {
// ignore conversion failures
}
const next = { full, initials };
PINYIN_CACHE.delete(cacheKey);
PINYIN_CACHE.set(cacheKey, next);
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) {
const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined;
if (oldestKey === undefined) break;
PINYIN_CACHE.delete(oldestKey);
}
return next;
}
export function resetPinyinCacheForTests(): void {
PINYIN_CACHE.clear();
}
export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } {
return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] };
}
export function tokenizeSearchQuery(query: string): string[] {
const normalized = normalizeText(query);
if (!normalized) return [];
return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean);
}
export function matchesSearchQuery(
query: string,
...fields: Array<string | null | undefined>
): boolean {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return true;
const normalizedFields = fields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => normalizeText(field));
if (normalizedFields.length === 0) return false;
// For dotted numeric input (IPv4-like), require contiguous literal match.
if (IPV4_LIKE_REGEX.test(normalizedQuery)) {
return normalizedFields.some((field) => field.includes(normalizedQuery));
}
const tokens = tokenizeSearchQuery(normalizedQuery);
if (tokens.length === 0) return false;
const sourceText = normalizedFields.join(" ");
const haystack = sourceText;
if (haystack.includes(normalizedQuery)) {
return true;
}
const haystackCompact = compactText(sourceText);
const compactQuery = compactText(normalizedQuery);
if (compactQuery && haystackCompact.includes(compactQuery)) {
return true;
}
if (tokens.every((token) => haystack.includes(token))) {
return true;
}
const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token));
if (!hasLatinToken) return false;
const { full, initials } = getPinyinVariants(sourceText);
if (!full && !initials) return false;
return tokens.every((token) => {
if (haystack.includes(token)) return true;
const compactToken = compactText(token);
return (
(full && full.includes(compactToken)) ||
(initials && initials.includes(compactToken))
);
});
}
/**
* Host search should avoid mixing label/group tokens with hostname/IP tokens.
* Otherwise queries like "山东 6-1" can accidentally match:
* - "山东" from group/label
* - "6" / "1" from hostname IP
* across different fields.
*/
export function matchesHostSearchQuery(
query: string,
hostLike: {
label?: string | null;
hostname?: string | null;
group?: string | null;
tags?: Array<string | null | undefined> | null;
},
): boolean {
return getHostSearchMatch(query, hostLike).matched;
}
type HostMatchField = "label" | "hostname" | "group" | "tag";
type HostMatchMethod = "literal" | "compact" | "pinyinFull" | "pinyinInitials";
type HostMatchPhase = "strict" | "loose";
export type HostSearchMatchDetail = {
segment: string;
field: HostMatchField;
method: HostMatchMethod;
phase: HostMatchPhase;
};
export type HostSearchMatchResult = {
matched: boolean;
phase: HostMatchPhase | "none";
score: number;
details: HostSearchMatchDetail[];
};
type HostSearchFieldSource = {
field: HostMatchField;
allowPinyin: boolean;
text: string;
compact: string;
};
function scoreHostMatch(detail: HostSearchMatchDetail): number {
return HOST_FIELD_SCORE[detail.field] + HOST_METHOD_SCORE[detail.method];
}
function selectBetterHostMatch(
left: HostSearchMatchResult,
right: HostSearchMatchResult,
): HostSearchMatchResult {
if (!left.matched) return right;
if (!right.matched) return left;
if (left.score !== right.score) {
return left.score > right.score ? left : right;
}
if (left.details.length !== right.details.length) {
return left.details.length < right.details.length ? left : right;
}
return left;
}
function matchSegmentAgainstSource(
segment: string,
source: HostSearchFieldSource,
): HostSearchMatchDetail | null {
if (source.text.includes(segment)) {
return {
segment,
field: source.field,
method: "literal",
phase: "strict",
};
}
// Keep punctuation semantic (e.g. 6-, 6-1, 10.6.1.8): no compact fallback.
if (SEARCH_QUERY_PUNCT_REGEX.test(segment)) return null;
const compactSegment = compactText(segment);
if (
compactSegment
&& source.field !== "hostname"
&& source.compact.includes(compactSegment)
) {
return {
segment,
field: source.field,
method: "compact",
phase: "loose",
};
}
if (!source.allowPinyin || !/[a-z]/i.test(segment)) return null;
const { full, initials } = getPinyinVariants(source.text);
if (full && full.includes(compactSegment)) {
return {
segment,
field: source.field,
method: "pinyinFull",
phase: "loose",
};
}
if (initials && initials.includes(compactSegment)) {
return {
segment,
field: source.field,
method: "pinyinInitials",
phase: "loose",
};
}
return null;
}
function evaluateHostFieldGroup(
segments: string[],
sources: HostSearchFieldSource[],
): HostSearchMatchResult {
if (sources.length === 0) {
return { matched: false, phase: "none", score: 0, details: [] };
}
const details: HostSearchMatchDetail[] = [];
let isStrict = true;
for (const segment of segments) {
let best: HostSearchMatchDetail | null = null;
let bestScore = -1;
for (const source of sources) {
const detail = matchSegmentAgainstSource(segment, source);
if (!detail) continue;
const score = scoreHostMatch(detail);
if (score > bestScore) {
best = detail;
bestScore = score;
}
}
if (!best) {
return { matched: false, phase: "none", score: 0, details: [] };
}
if (best.phase !== "strict") {
isStrict = false;
}
details.push(best);
}
const phase: HostMatchPhase = isStrict ? "strict" : "loose";
const baseScore = details.reduce((sum, detail) => sum + scoreHostMatch(detail), 0);
return {
matched: true,
phase,
score: baseScore + HOST_PHASE_SCORE_BONUS[phase],
details,
};
}
export function getHostSearchMatch(
query: string,
hostLike: {
label?: string | null;
hostname?: string | null;
group?: string | null;
tags?: Array<string | null | undefined> | null;
},
): HostSearchMatchResult {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) {
return { matched: true, phase: "strict", score: HOST_PHASE_SCORE_BONUS.strict, details: [] };
}
const querySegments = normalizedQuery
.split(SEARCH_QUERY_SEGMENT_SPLIT_REGEX)
.map((segment) => segment.trim())
.filter(Boolean);
if (querySegments.length === 0) {
return { matched: true, phase: "strict", score: HOST_PHASE_SCORE_BONUS.strict, details: [] };
}
const label = normalizeText(hostLike.label ?? "");
const group = normalizeText(hostLike.group ?? "");
const hostname = normalizeText(hostLike.hostname ?? "");
const tags = (hostLike.tags ?? [])
.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
.map((tag) => normalizeText(tag));
const humanSources: HostSearchFieldSource[] = [];
if (label) {
humanSources.push({
field: "label",
allowPinyin: true,
text: label,
compact: compactText(label),
});
}
if (group) {
humanSources.push({
field: "group",
allowPinyin: true,
text: group,
compact: compactText(group),
});
}
for (const tag of tags) {
humanSources.push({
field: "tag",
allowPinyin: true,
text: tag,
compact: compactText(tag),
});
}
const networkSources: HostSearchFieldSource[] = hostname
? [{
field: "hostname",
allowPinyin: false,
text: hostname,
compact: compactText(hostname),
}]
: [];
const humanResult = evaluateHostFieldGroup(querySegments, humanSources);
const networkResult = evaluateHostFieldGroup(querySegments, networkSources);
return selectBetterHostMatch(humanResult, networkResult);
}

145
lib/sftpFileUtils.test.ts Normal file
View File

@@ -0,0 +1,145 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
captureDropPayload,
formatDropScanLabel,
getFileExtension,
hasFileExtension,
localTreeToDropEntries,
materializeDropEntries,
type LocalTreeListEntry,
} from "./sftpFileUtils.ts";
test("hasFileExtension identifies extensionless and dotted filenames", () => {
assert.equal(hasFileExtension("nginx"), false);
assert.equal(hasFileExtension("my-binary"), false);
assert.equal(hasFileExtension(".git"), false);
assert.equal(getFileExtension("nginx"), "file");
assert.equal(hasFileExtension("readme.txt"), true);
assert.equal(hasFileExtension(".bashrc"), false);
assert.equal(hasFileExtension("archive.tar.gz"), true);
});
test("formatDropScanLabel summarizes dropped root names", () => {
assert.equal(formatDropScanLabel([]), "Scanning files...");
assert.equal(formatDropScanLabel([{ name: "docs", isDirectory: true }]), "docs");
assert.equal(
formatDropScanLabel([
{ name: "a", isDirectory: true },
{ name: "b", isDirectory: true },
]),
"a, b",
);
assert.equal(
formatDropScanLabel([
{ name: "a", isDirectory: true },
{ name: "b", isDirectory: true },
{ name: "c", isDirectory: true },
]),
"a, b +1",
);
});
test("captureDropPayload reads webkit entries synchronously", () => {
const file = new File(["x"], "note.txt");
Object.defineProperty(file, "path", { value: "/tmp/note.txt" });
const dataTransfer = {
items: [{
kind: "file",
getAsFile: () => file,
webkitGetAsEntry: () => ({
name: "note.txt",
isFile: true,
isDirectory: false,
}),
}],
files: [file],
} as unknown as DataTransfer;
const payload = captureDropPayload(dataTransfer);
assert.equal(payload.roots.length, 1);
assert.equal(payload.roots[0].name, "note.txt");
assert.equal(payload.roots[0].isDirectory, false);
assert.equal(payload.roots[0].localPath, "/tmp/note.txt");
});
test("materializeDropEntries prefers listLocalTree for directory roots with paths", async () => {
const progress: Array<{ fileCount: number; directoryCount: number }> = [];
const tree: LocalTreeListEntry[] = [
{
localPath: "/tmp/project",
relativePath: "project",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/project/src",
relativePath: "project/src",
type: "directory",
size: 0,
lastModified: 1,
},
{
localPath: "/tmp/project/src/main.ts",
relativePath: "project/src/main.ts",
type: "file",
size: 12,
lastModified: 2,
},
];
const entries = await materializeDropEntries(
{
roots: [{
name: "project",
isDirectory: true,
localPath: "/tmp/project",
}],
filesFallback: [],
},
{
listLocalTree: async (path, options) => {
assert.equal(path, "/tmp/project");
options?.onProgress?.({ fileCount: 1, directoryCount: 2, entryCount: 3 });
return tree;
},
onProgress: (p) => progress.push({ fileCount: p.fileCount, directoryCount: p.directoryCount }),
},
);
assert.deepEqual(
entries.map((entry) => ({
relativePath: entry.relativePath,
isDirectory: entry.isDirectory,
localPath: entry.localPath,
size: entry.size,
})),
[
{ relativePath: "project", isDirectory: true, localPath: "/tmp/project", size: undefined },
{ relativePath: "project/src", isDirectory: true, localPath: "/tmp/project/src", size: undefined },
{
relativePath: "project/src/main.ts",
isDirectory: false,
localPath: "/tmp/project/src/main.ts",
size: 12,
},
],
);
assert.ok(progress.some((p) => p.fileCount === 1 && p.directoryCount === 2));
});
test("localTreeToDropEntries preserves file sizes without File handles", () => {
const entries = localTreeToDropEntries([{
localPath: "/tmp/a.txt",
relativePath: "a.txt",
type: "file",
size: 42,
lastModified: 9,
}]);
assert.equal(entries[0].file, null);
assert.equal(entries[0].localPath, "/tmp/a.txt");
assert.equal(entries[0].size, 42);
});

812
lib/sftpFileUtils.ts Normal file
View File

@@ -0,0 +1,812 @@
/**
* SFTP File Utilities
* Helper functions for file type detection and extension handling
*/
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
// Known binary file extensions - files that should never be opened as text
const BINARY_EXTENSIONS = new Set([
// Images
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff', 'tif',
'heic', 'heif', 'avif', 'jfif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
// Audio
'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'aiff', 'opus',
// Video
'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg',
// Archives
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lz', 'lzma', 'zst',
'tgz', 'tbz2', 'txz', 'cab', 'iso', 'dmg',
// Executables
'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'msi', 'deb', 'rpm',
'apk', 'ipa', 'jar', 'war', 'ear',
// Documents (binary formats)
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
// Fonts
'ttf', 'otf', 'woff', 'woff2', 'eot',
// Database
'db', 'sqlite', 'sqlite3', 'mdb', 'accdb',
// Object files
'o', 'obj', 'pyc', 'pyo', 'class', 'beam',
// Other binary
'swf', 'fla', 'blend', 'unity3d', 'unitypackage',
]);
// Language IDs for syntax highlighting
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
tsx: 'typescript',
py: 'python',
pyw: 'python',
pyi: 'python',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
bat: 'batch',
cmd: 'batch',
ps1: 'powershell',
psm1: 'powershell',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
go: 'go',
rs: 'rust',
rb: 'ruby',
php: 'php',
pl: 'perl',
lua: 'lua',
r: 'r',
R: 'r',
swift: 'swift',
dart: 'dart',
cs: 'csharp',
fs: 'fsharp',
vb: 'vb',
html: 'html',
htm: 'html',
xhtml: 'html',
css: 'css',
scss: 'scss',
sass: 'sass',
less: 'less',
json: 'json',
jsonc: 'jsonc',
json5: 'json5',
xml: 'xml',
xsl: 'xml',
xslt: 'xml',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
ini: 'ini',
conf: 'ini',
cfg: 'ini',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
md: 'markdown',
markdown: 'markdown',
mdx: 'markdown',
txt: 'plaintext',
log: 'plaintext',
vue: 'vue',
svelte: 'svelte',
dockerfile: 'dockerfile',
makefile: 'makefile',
diff: 'diff',
patch: 'diff',
};
/**
* Get the file extension from a filename
* For files without extension, returns 'file'
*/
export function getFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot === -1 || lastDot === 0) {
return 'file'; // No extension or hidden file without extension
}
return fileName.slice(lastDot + 1).toLowerCase();
}
/** True when the filename has a real extension (e.g. `foo.txt`), not a dot-only name like `.git`. */
export function hasFileExtension(fileName: string): boolean {
return fileName.lastIndexOf('.') > 0;
}
/**
* Check if a file is definitely a binary file based on its extension.
* Used to exclude files from "Edit" option in context menu.
*/
export function isKnownBinaryFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
return BINARY_EXTENSIONS.has(ext);
}
/**
* Get language ID for syntax highlighting
*/
export function getLanguageId(fileName: string): string {
const ext = getFileExtension(fileName);
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
}
/**
* Get a user-friendly name for a language
*/
export function getLanguageName(languageId: string): string {
const names: Record<string, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
python: 'Python',
shell: 'Shell',
batch: 'Batch',
powershell: 'PowerShell',
c: 'C',
cpp: 'C++',
java: 'Java',
kotlin: 'Kotlin',
go: 'Go',
rust: 'Rust',
ruby: 'Ruby',
php: 'PHP',
perl: 'Perl',
lua: 'Lua',
r: 'R',
swift: 'Swift',
dart: 'Dart',
csharp: 'C#',
fsharp: 'F#',
vb: 'Visual Basic',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
sass: 'Sass',
less: 'Less',
json: 'JSON',
jsonc: 'JSON with Comments',
json5: 'JSON5',
xml: 'XML',
yaml: 'YAML',
toml: 'TOML',
ini: 'INI',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
plaintext: 'Plain Text',
vue: 'Vue',
svelte: 'Svelte',
dockerfile: 'Dockerfile',
makefile: 'Makefile',
diff: 'Diff',
};
return names[languageId] || languageId.charAt(0).toUpperCase() + languageId.slice(1);
}
/**
* File opener application types
* - 'builtin-editor': Built-in text editor (Monaco)
* - 'system-app': External system application (stores path)
*/
export type FileOpenerType = 'builtin-editor' | 'system-app';
/**
* System application info for file associations
*/
export interface SystemAppInfo {
path: string; // Path to the executable/app
name: string; // Display name
}
/**
* File association record
*/
export interface FileAssociation {
extension: string;
openerType: FileOpenerType;
systemApp?: SystemAppInfo; // Only set when openerType is 'system-app'
}
/**
* Get all supported language IDs for syntax highlighting dropdown
*/
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Represents a file or directory entry from drag-and-drop
* This includes the relative path for nested files in folders
*/
export interface DropEntry {
file: File | null; // null for directory entries
localPath?: string;
size?: number;
relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt")
isDirectory: boolean;
}
/** Local tree row from main-process `listLocalTree`. */
export interface LocalTreeListEntry {
localPath: string;
relativePath: string;
type: "file" | "directory";
size: number;
lastModified: number;
}
/** Live scan counters while expanding a dropped folder. */
export interface DropScanProgress {
fileCount: number;
directoryCount: number;
entryCount: number;
/** Optional UI label (e.g. root folder names). */
label?: string;
}
/**
* Sync snapshot of a drop. DataTransfer must be read before any await —
* after that, items/files may be empty.
*/
export interface CapturedDropRoot {
name: string;
isDirectory: boolean;
localPath?: string;
file?: File | null;
size?: number;
/** Folder-picker relative path when the File carries webkitRelativePath */
relativePath?: string;
/** webkit entry for Chromium walk fallback when no local path is available */
fsEntry?: FileSystemEntry;
}
export interface CapturedDropPayload {
roots: CapturedDropRoot[];
filesFallback: File[];
}
export interface MaterializeDropOptions {
listLocalTree?: (
path: string,
options?: {
onProgress?: (progress: DropScanProgress) => void;
abortSignal?: AbortSignal;
},
) => Promise<LocalTreeListEntry[]>;
onProgress?: (progress: DropScanProgress) => void;
/** Cooperative cancel for webkit walks and native listLocalTree. */
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
}
export const getDropEntryLocalPath = (entry: DropEntry): string | undefined =>
entry.localPath ?? (entry.file ? getPathForFile(entry.file) : undefined);
const createDropEntriesFromFiles = (files: FileList | File[]): DropEntry[] => {
const results: DropEntry[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
results.push({
file,
localPath: getPathForFile(file),
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
isDirectory: false,
});
}
return results;
};
/**
* Convert a FileSystemEntry to a File
*/
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
/**
* Read all entries from a directory reader
* Handles the fact that readEntries may not return all entries at once
*/
async function readAllDirectoryEntries(
directoryReader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> {
const allEntries: FileSystemEntry[] = [];
// Keep reading until we get an empty result
let entries: FileSystemEntry[];
do {
entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});
for (const entry of entries) {
allEntries.push(entry);
}
} while (entries.length > 0);
return allEntries;
}
function joinLocalRelativePath(rootPath: string, relativePath: string): string {
const normalizedRelative = relativePath.replace(/\\/g, "/");
const parts = normalizedRelative.split("/");
// relativePath is rooted at the drop root name; local path already points at that root.
const nested = parts.length > 1 ? parts.slice(1).join("/") : "";
if (!nested) return rootPath;
const separator = rootPath.includes("\\") ? "\\" : "/";
return rootPath + separator + nested.replace(/\//g, separator);
}
/**
* Process file system entries iteratively (non-recursive) to handle large folders.
* Prefer reconstructing local paths from the drop root so we can skip per-file
* `entry.file()` when Electron already exposed the folder path.
*/
export function isDropScanCancelledError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = (error as { code?: string }).code;
return code === "ERR_DROP_SCAN_CANCELLED" || code === "ERR_LOCAL_TREE_CANCELLED";
}
function throwIfDropScanCancelled(options: {
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
}): void {
if (options.abortSignal?.aborted || options.isCancelled?.()) {
const error = new Error("Drop scan cancelled");
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
throw error;
}
}
async function processEntriesIteratively(
rootEntries: FileSystemEntry[],
options: {
rootPathByName?: Map<string, string>;
onProgress?: (progress: DropScanProgress) => void;
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
} = {},
): Promise<DropEntry[]> {
const results: DropEntry[] = [];
const rootPathByName = options.rootPathByName ?? new Map<string, string>();
// Index-based queue avoids O(n²) Array.shift on huge trees.
const queue: Array<{ entry: FileSystemEntry; basePath: string }> = [];
for (const entry of rootEntries) {
queue.push({ entry, basePath: "" });
}
let queueIndex = 0;
let processedCount = 0;
let fileCount = 0;
let directoryCount = 0;
const YIELD_INTERVAL = 100;
const PROGRESS_INTERVAL = 32;
const reportProgress = (force = false) => {
if (!options.onProgress) return;
if (!force && processedCount % PROGRESS_INTERVAL !== 0) return;
options.onProgress({
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
});
};
while (queueIndex < queue.length) {
throwIfDropScanCancelled(options);
const { entry, basePath } = queue[queueIndex++];
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
const rootName = relativePath.split("/")[0] ?? entry.name;
const rootLocalPath = rootPathByName.get(rootName);
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
if (rootLocalPath) {
// Native path is enough for stream upload; avoid Chromium File materialization.
results.push({
file: null,
localPath: joinLocalRelativePath(rootLocalPath, relativePath),
relativePath,
isDirectory: false,
});
fileCount++;
} else {
try {
const file = await entryToFile(fileEntry);
results.push({
file,
relativePath,
isDirectory: false,
});
fileCount++;
} catch (error) {
console.warn(`Failed to read file entry: ${entry.name}`, error);
}
}
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
results.push({
file: null,
localPath: rootLocalPath ? joinLocalRelativePath(rootLocalPath, relativePath) : undefined,
relativePath,
isDirectory: true,
});
directoryCount++;
try {
const reader = dirEntry.createReader();
const childEntries = await readAllDirectoryEntries(reader);
for (const childEntry of childEntries) {
queue.push({ entry: childEntry, basePath: relativePath });
}
} catch (error) {
console.warn(`Failed to read directory: ${entry.name}`, error);
}
}
processedCount++;
reportProgress();
if (processedCount % YIELD_INTERVAL === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
reportProgress(true);
return results;
}
/**
* Get the local file path for a File object using Electron's webUtils API
* Falls back to the legacy file.path property if webUtils is not available
*/
export function getPathForFile(file: File): string | undefined {
try {
// Try Electron's webUtils API (exposed via preload)
const path = netcattyBridge.get()?.getPathForFile?.(file);
if (path) return path;
// Fallback: try legacy file.path property
return (file as File & { path?: string }).path;
} catch {
return undefined;
}
}
/** Build a short label for the scanning task (folder names visible immediately). */
export function formatDropScanLabel(roots: readonly CapturedDropRoot[]): string {
const names = roots.map((root) => root.name).filter(Boolean);
if (names.length === 0) return "Scanning files...";
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]}, ${names[1]}`;
return `${names[0]}, ${names[1]} +${names.length - 2}`;
}
/**
* Synchronously capture drop roots. Must run during the drop/paste event —
* before any await — or DataTransfer becomes empty.
*/
export function captureDropPayload(dataTransfer: DataTransfer): CapturedDropPayload {
const filesFallback: File[] = [];
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {
filesFallback.push(files[i]);
}
const roots: CapturedDropRoot[] = [];
const items = dataTransfer.items;
const relativePathForFile = (file: File): string | undefined => {
const relative = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
return relative && relative.length > 0 ? relative : undefined;
};
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === "function") {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== "file") continue;
const entry = item.webkitGetAsEntry();
const file = typeof item.getAsFile === "function" ? item.getAsFile() : (filesFallback[i] ?? null);
const localPath = file ? getPathForFile(file) : undefined;
const relativePath = file ? relativePathForFile(file) : undefined;
if (entry) {
roots.push({
name: entry.name,
isDirectory: entry.isDirectory,
localPath,
file: entry.isFile ? file : null,
size: file?.size,
relativePath: entry.isFile ? relativePath : undefined,
fsEntry: entry,
});
continue;
}
if (file) {
roots.push({
name: file.name,
isDirectory: false,
localPath: getPathForFile(file),
file,
size: file.size,
relativePath,
});
}
}
} else {
for (const file of filesFallback) {
roots.push({
name: file.name,
isDirectory: false,
localPath: getPathForFile(file),
file,
size: file.size,
relativePath: relativePathForFile(file),
});
}
}
return { roots, filesFallback };
}
/** Map main-process local tree rows into upload DropEntry records. */
export function localTreeToDropEntries(tree: readonly LocalTreeListEntry[]): DropEntry[] {
return tree.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
localPath: entry.localPath,
relativePath: entry.relativePath,
isDirectory: true,
// Do not use directory metadata size in conflict / compressed totals.
};
}
return {
file: null,
localPath: entry.localPath,
relativePath: entry.relativePath,
isDirectory: false,
size: entry.size,
};
});
}
function countDropEntries(entries: readonly DropEntry[]): DropScanProgress {
let fileCount = 0;
let directoryCount = 0;
for (const entry of entries) {
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
return {
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
};
}
/**
* Expand a captured drop into full DropEntry rows.
* Prefer Electron `listLocalTree` (native fs) for directory roots with a local
* path; fall back to Chromium FileSystemEntry walk only when necessary.
*/
export async function materializeDropEntries(
payload: CapturedDropPayload,
options: MaterializeDropOptions = {},
): Promise<DropEntry[]> {
const { listLocalTree, onProgress, abortSignal, isCancelled } = options;
const results: DropEntry[] = [];
let fileCount = 0;
let directoryCount = 0;
const report = (label?: string, forcePartial?: DropScanProgress) => {
if (!onProgress) return;
if (forcePartial) {
onProgress({
...forcePartial,
label,
});
return;
}
onProgress({
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
label,
});
};
const nativeDirectoryRoots: CapturedDropRoot[] = [];
const webkitDirectoryRoots: CapturedDropRoot[] = [];
const fileRoots: CapturedDropRoot[] = [];
for (const root of payload.roots) {
if (root.isDirectory) {
if (root.localPath && listLocalTree) {
nativeDirectoryRoots.push(root);
} else if (root.fsEntry) {
webkitDirectoryRoots.push(root);
} else {
console.warn(`[SFTP] Skipping directory drop root without path or entry: ${root.name}`);
}
continue;
}
fileRoots.push(root);
}
throwIfDropScanCancelled({ abortSignal, isCancelled });
// Parallel native walks — one IPC per root folder, much faster than webkit.
if (nativeDirectoryRoots.length > 0 && listLocalTree) {
// Cumulative progress across roots: each walk reports its own counts.
const partialByRoot = new Map<string, DropScanProgress>();
const emitCombined = (label?: string) => {
let files = fileCount;
let dirs = directoryCount;
for (const partial of partialByRoot.values()) {
files += partial.fileCount;
dirs += partial.directoryCount;
}
report(label, { fileCount: files, directoryCount: dirs, entryCount: files + dirs });
};
// Local controller so a single root failure aborts sibling walks too.
const siblingAbort = new AbortController();
const stopSiblingScans = () => {
try {
siblingAbort.abort();
} catch {
// ignore
}
};
if (abortSignal) {
if (abortSignal.aborted) stopSiblingScans();
else abortSignal.addEventListener("abort", stopSiblingScans, { once: true });
}
const walkSignal = siblingAbort.signal;
const walkCancelled = () => (
walkSignal.aborted || abortSignal?.aborted === true || isCancelled?.() === true
);
const walkPromises = nativeDirectoryRoots.map(async (root) => {
throwIfDropScanCancelled({ abortSignal: walkSignal, isCancelled: walkCancelled });
const tree = await listLocalTree(root.localPath!, {
abortSignal: walkSignal,
onProgress: (partial) => {
partialByRoot.set(root.localPath!, partial);
emitCombined(root.name);
},
});
return { root, tree };
});
let trees: Array<{ root: CapturedDropRoot; tree: LocalTreeListEntry[] }>;
try {
trees = await Promise.all(walkPromises);
} catch (error) {
stopSiblingScans();
// Drain remaining native walks so retry does not pile more I/O on top.
await Promise.allSettled(walkPromises);
throw error;
} finally {
abortSignal?.removeEventListener("abort", stopSiblingScans);
}
for (const { root, tree } of trees) {
const entries = localTreeToDropEntries(tree);
for (const entry of entries) {
results.push(entry);
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
partialByRoot.delete(root.localPath!);
report(root.name);
}
}
for (const root of fileRoots) {
results.push({
file: root.file ?? null,
localPath: root.localPath,
relativePath: root.relativePath || root.name,
isDirectory: false,
size: root.size ?? root.file?.size,
});
fileCount += 1;
}
if (fileRoots.length > 0) {
report();
}
if (webkitDirectoryRoots.length > 0) {
const rootPathByName = new Map<string, string>();
for (const root of webkitDirectoryRoots) {
if (root.localPath) rootPathByName.set(root.name, root.localPath);
}
const walked = await processEntriesIteratively(
webkitDirectoryRoots.map((root) => root.fsEntry!).filter(Boolean),
{
rootPathByName,
abortSignal,
isCancelled,
onProgress: (partial) => {
report(undefined, {
fileCount: fileCount + partial.fileCount,
directoryCount: directoryCount + partial.directoryCount,
entryCount: fileCount + directoryCount + partial.entryCount,
});
},
},
);
// Attach reconstructed paths when we only know the root path.
for (const entry of walked) {
if (!entry.localPath) {
const rootName = entry.relativePath.split("/")[0];
const rootPath = rootPathByName.get(rootName);
if (rootPath) {
entry.localPath = joinLocalRelativePath(rootPath, entry.relativePath);
} else if (entry.file) {
entry.localPath = getPathForFile(entry.file);
}
}
results.push(entry);
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
report();
}
if (results.length === 0 && payload.filesFallback.length > 0) {
const fallback = createDropEntriesFromFiles(payload.filesFallback);
const counts = countDropEntries(fallback);
report(undefined, counts);
return fallback;
}
report(undefined, { fileCount, directoryCount, entryCount: fileCount + directoryCount });
return results;
}
/**
* Extract all files and directories from a DataTransfer object.
* Supports both regular files and folders dropped from the OS.
*
* Prefer Electron native tree walk when local paths are available; otherwise
* use webkitGetAsEntry with a path-reconstruction fast path.
*/
export async function extractDropEntries(
dataTransfer: DataTransfer,
options: MaterializeDropOptions = {},
): Promise<DropEntry[]> {
const payload = captureDropPayload(dataTransfer);
const bridge = netcattyBridge.get();
return materializeDropEntries(payload, {
listLocalTree: options.listLocalTree
?? (bridge?.listLocalTree
? (path, treeOptions) => bridge.listLocalTree!(path, treeOptions)
: undefined),
onProgress: options.onProgress,
abortSignal: options.abortSignal,
isCancelled: options.isCancelled,
});
}

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import type React from "react";
import {
MIDDLE_MOUSE_BUTTON,
handleTabMiddleClickClose,
handleTabMiddleMouseDown,
} from "./tabInteractions.ts";
interface FakeMouseEvent {
button: number;
preventDefault: () => void;
stopPropagation: () => void;
}
const makeEvent = (button: number) => {
const calls = { preventDefault: 0, stopPropagation: 0 };
const event = {
button,
preventDefault: () => {
calls.preventDefault++;
},
stopPropagation: () => {
calls.stopPropagation++;
},
} satisfies FakeMouseEvent;
return { event: event as unknown as React.MouseEvent, calls };
};
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
let closed = 0;
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
handleTabMiddleClickClose(event, () => {
closed++;
});
assert.equal(closed, 1);
assert.equal(calls.preventDefault, 1);
assert.equal(calls.stopPropagation, 1);
});
test("handleTabMiddleClickClose ignores left and right clicks", () => {
for (const button of [0, 2]) {
let closed = 0;
const { event, calls } = makeEvent(button);
handleTabMiddleClickClose(event, () => {
closed++;
});
assert.equal(closed, 0, `button ${button} must not close the tab`);
assert.equal(calls.preventDefault, 0);
}
});
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
handleTabMiddleMouseDown(middle.event);
assert.equal(middle.calls.preventDefault, 1);
const left = makeEvent(0);
handleTabMiddleMouseDown(left.event);
assert.equal(left.calls.preventDefault, 0);
});

34
lib/tabInteractions.ts Normal file
View File

@@ -0,0 +1,34 @@
import type React from "react";
/**
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
* 0 = left/primary, 1 = middle, 2 = right/secondary.
*/
export const MIDDLE_MOUSE_BUTTON = 1;
/**
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
* default there stops the panning-cursor overlay from appearing when a user
* middle-clicks a tab to close it (#1044).
*/
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
if (e.button === MIDDLE_MOUSE_BUTTON) {
e.preventDefault();
}
};
/**
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
* a completed non-primary click. Left clicks (tab activation) and right clicks
* (context menu) are ignored so existing behavior is untouched.
*/
export const handleTabMiddleClickClose = (
e: React.MouseEvent,
close: () => void,
): void => {
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
e.preventDefault();
e.stopPropagation();
close();
};

110
lib/textZip.ts Normal file
View File

@@ -0,0 +1,110 @@
export interface TextZipFile {
name: string;
content: string;
}
const ZIP_UTF8_FLAG = 0x0800;
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
return value >>> 0;
});
const crc32 = (bytes: Uint8Array): number => {
let crc = 0xffffffff;
bytes.forEach((byte) => {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
});
return (crc ^ 0xffffffff) >>> 0;
};
const pushUint16 = (parts: number[], value: number) => {
parts.push(value & 0xff, (value >>> 8) & 0xff);
};
const pushUint32 = (parts: number[], value: number) => {
parts.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff);
};
const concatZipParts = (header: number[], ...parts: Uint8Array[]): Uint8Array => {
const headerBytes = Uint8Array.from(header);
const chunk = new Uint8Array(
headerBytes.length + parts.reduce((total, part) => total + part.length, 0),
);
let offset = 0;
chunk.set(headerBytes, offset);
offset += headerBytes.length;
parts.forEach((part) => {
chunk.set(part, offset);
offset += part.length;
});
return chunk;
};
export const buildTextFilesZipBlob = (files: TextZipFile[]): Blob => {
const encoder = new TextEncoder();
const chunks: Uint8Array[] = [];
const centralDirectory: Uint8Array[] = [];
let offset = 0;
files.forEach((file) => {
const nameBytes = encoder.encode(file.name);
const contentBytes = encoder.encode(file.content);
const checksum = crc32(contentBytes);
const localHeader: number[] = [];
pushUint32(localHeader, 0x04034b50);
pushUint16(localHeader, 20);
pushUint16(localHeader, ZIP_UTF8_FLAG);
pushUint16(localHeader, 0);
pushUint16(localHeader, 0);
pushUint16(localHeader, 0);
pushUint32(localHeader, checksum);
pushUint32(localHeader, contentBytes.length);
pushUint32(localHeader, contentBytes.length);
pushUint16(localHeader, nameBytes.length);
pushUint16(localHeader, 0);
const localChunk = concatZipParts(localHeader, nameBytes, contentBytes);
chunks.push(localChunk);
const centralHeader: number[] = [];
pushUint32(centralHeader, 0x02014b50);
pushUint16(centralHeader, 20);
pushUint16(centralHeader, 20);
pushUint16(centralHeader, ZIP_UTF8_FLAG);
pushUint16(centralHeader, 0);
pushUint16(centralHeader, 0);
pushUint16(centralHeader, 0);
pushUint32(centralHeader, checksum);
pushUint32(centralHeader, contentBytes.length);
pushUint32(centralHeader, contentBytes.length);
pushUint16(centralHeader, nameBytes.length);
pushUint16(centralHeader, 0);
pushUint16(centralHeader, 0);
pushUint16(centralHeader, 0);
pushUint16(centralHeader, 0);
pushUint32(centralHeader, 0);
pushUint32(centralHeader, offset);
centralDirectory.push(concatZipParts(centralHeader, nameBytes));
offset += localChunk.length;
});
const centralDirectorySize = centralDirectory.reduce((total, chunk) => total + chunk.length, 0);
const endRecord: number[] = [];
pushUint32(endRecord, 0x06054b50);
pushUint16(endRecord, 0);
pushUint16(endRecord, 0);
pushUint16(endRecord, files.length);
pushUint16(endRecord, files.length);
pushUint32(endRecord, centralDirectorySize);
pushUint32(endRecord, offset);
pushUint16(endRecord, 0);
return new Blob([...chunks, ...centralDirectory, new Uint8Array(endRecord)], {
type: "application/zip",
});
};

View File

@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { uploadFoldersCompressed } from "./uploadCompressed";
import type { DropEntry } from "./sftpFileUtils";
test("compressed folder uploads use reconstructed drop-entry paths", async () => {
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
checkCompressedUploadSupport: async () => ({
supported: false,
localTar: false,
remoteTar: false,
}),
},
},
});
try {
const entry: DropEntry = {
file: { name: "child.txt", size: 1 } as File,
localPath: "/tmp/example-folder/child.txt",
relativePath: "example-folder/child.txt",
isDirectory: false,
};
const results = await uploadFoldersCompressed(
[["example-folder", [entry]]],
"/remote",
"sftp-1",
);
assert.deepEqual(results, [{
fileName: "example-folder",
success: false,
error: "Compressed upload not supported - fallback needed",
}]);
} finally {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: previousWindow,
});
}
}
});
test("compressed folder totals exclude directory entries", async () => {
const previousWindow = globalThis.window;
const started: Array<{ totalBytes: number; folderPath: string }> = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
checkCompressedUploadSupport: async () => ({
supported: true,
localTar: true,
remoteTar: true,
}),
startCompressedUpload: async (options: { totalBytes: number; folderPath: string; compressionId: string }) => {
started.push({ totalBytes: options.totalBytes, folderPath: options.folderPath });
return { compressionId: options.compressionId, success: true };
},
},
},
});
try {
const results = await uploadFoldersCompressed(
[["docs", [
{ file: null, localPath: "/tmp/docs", relativePath: "docs", isDirectory: true, size: 4096 },
{ file: null, localPath: "/tmp/docs/sub", relativePath: "docs/sub", isDirectory: true, size: 4096 },
{ file: null, localPath: "/tmp/docs/a.txt", relativePath: "docs/a.txt", isDirectory: false, size: 10 },
]]],
"/remote",
"sftp-1",
);
assert.deepEqual(results, [{ fileName: "docs", success: true }]);
assert.deepEqual(started, [{ totalBytes: 10, folderPath: "/tmp/docs" }]);
} finally {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: previousWindow,
});
}
}
});

187
lib/uploadCompressed.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { DropEntry } from "./sftpFileUtils";
import { getDropEntryLocalPath } from "./sftpFileUtils";
const getDropEntrySize = (entry: DropEntry): number => entry.file?.size ?? entry.size ?? 0;
import type { UploadCallbacks, UploadResult } from "./uploadService.types";
import type { UploadController } from "./uploadController";
const formatUploadError = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
targetPath: string,
sftpId: string,
callbacks?: UploadCallbacks,
controller?: UploadController
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
// Import the compressed upload service
const { startCompressedUpload, checkCompressedUploadSupport } = await import('../infrastructure/services/compressUploadService');
for (const [folderName, entries] of folderEntries) {
if (controller?.isCancelled()) {
break;
}
// Prefer any file-like entry with a resolvable local path (native tree scans
// set localPath without a browser File handle).
const firstFile = entries.find((entry) => (
!entry.isDirectory && (!!entry.file || !!getDropEntryLocalPath(entry))
));
if (!firstFile) {
// Empty folder - mark for fallback to regular upload which will create the directory
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
continue;
}
const localFilePath = getDropEntryLocalPath(firstFile);
if (!localFilePath) {
results.push({ fileName: folderName, success: false, error: "Could not get local file path" });
continue;
}
// Extract folder path from the first file path
// Use DropEntry.relativePath which works for both file input and drag-drop scenarios
// For file input: webkitRelativePath is set (e.g., "folder/subdir/file.txt")
// For drag-drop: DropEntry.relativePath contains the correct path from extractDropEntries
const relativePath = firstFile.relativePath
|| (firstFile.file as (File & { webkitRelativePath?: string }) | null)?.webkitRelativePath
|| firstFile.file?.name
|| folderName;
// Normalize path separators for cross-platform compatibility
const normalizePathSeparators = (path: string) => path.replace(/\\/g, '/');
const normalizedLocalPath = normalizePathSeparators(localFilePath);
const normalizedRelativePath = normalizePathSeparators(relativePath);
// Calculate the root folder path by removing the full relativePath from localFilePath
// For example: if localFilePath is "/Users/rice/Downloads/110-temp/insideServer/subdir/file.txt"
// and relativePath is "insideServer/subdir/file.txt", we want "/Users/rice/Downloads/110-temp/insideServer"
let folderPath = localFilePath;
if (normalizedRelativePath && normalizedLocalPath.endsWith(normalizedRelativePath)) {
// Remove the relativePath from the end to get the base directory
const basePath = localFilePath.substring(0, localFilePath.length - relativePath.length);
// Remove trailing slash/backslash if present
const cleanBasePath = basePath.replace(/[/\\]$/, '');
// Add the folder name to get the actual folder path
folderPath = cleanBasePath + (cleanBasePath ? (localFilePath.includes('\\') ? '\\' : '/') : '') + folderName;
} else {
// Fallback: try to extract based on folder name with normalized separators
const normalizedFolderPattern1 = '/' + folderName + '/';
const normalizedFolderPattern2 = '\\' + folderName + '\\';
const folderIndex1 = normalizedLocalPath.lastIndexOf(normalizedFolderPattern1);
const folderIndex2 = localFilePath.lastIndexOf(normalizedFolderPattern2);
const folderIndex = Math.max(folderIndex1, folderIndex2);
if (folderIndex >= 0) {
folderPath = localFilePath.substring(0, folderIndex + folderName.length + 1);
} else {
// Last resort: remove just the filename (original logic)
const pathParts = normalizedRelativePath.split('/');
if (pathParts.length > 1) {
const fileName = pathParts[pathParts.length - 1];
if (normalizedLocalPath.endsWith(fileName)) {
folderPath = localFilePath.substring(0, localFilePath.length - fileName.length - 1);
}
} else {
// Single file, get its parent directory
const lastSlash = Math.max(localFilePath.lastIndexOf('/'), localFilePath.lastIndexOf('\\'));
if (lastSlash > 0) {
folderPath = localFilePath.substring(0, lastSlash);
}
}
}
}
let taskId: string | null = null; // Declare taskId outside try block for error handling
try {
// Check if compressed upload is supported
const support = await checkCompressedUploadSupport(sftpId);
if (!support.supported) {
// Fall back to regular upload for this folder
results.push({
fileName: folderName,
success: false,
error: "Compressed upload not supported - fallback needed"
});
continue;
}
const compressionId = crypto.randomUUID();
// Check for cancellation before starting
if (controller?.isCancelled()) {
results.push({ fileName: folderName, success: false, cancelled: true });
break;
}
// Register compression ID with controller for cancellation support
controller?.addActiveCompression(compressionId);
// Create a task for this folder compression
// Path-only drop entries (listLocalTree) carry size without a File handle.
const fileEntries = entries.filter((entry) => !entry.isDirectory);
const totalBytes = fileEntries.reduce((sum, entry) => sum + getDropEntrySize(entry), 0);
taskId = compressionId;
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: taskId,
fileName: folderName,
displayName: `${folderName} (compressed)`,
isDirectory: true,
progressMode: 'bytes',
totalBytes,
transferredBytes: 0,
speed: 0,
fileCount: fileEntries.length,
completedCount: 0,
sourcePath: folderPath,
controlKind: 'compressed-upload',
});
}
// Start compressed upload
const result = await startCompressedUpload(
{
compressionId,
folderPath,
targetPath,
sftpId,
folderName,
totalBytes,
},
);
controller?.removeActiveCompression(compressionId);
if (result.success) {
results.push({ fileName: folderName, success: true });
} else if (result.error?.includes('cancelled') || controller?.isCancelled()) {
// Handle cancellation
results.push({ fileName: folderName, success: false, cancelled: true });
} else {
results.push({ fileName: folderName, success: false, error: result.error });
}
} catch (error) {
const errorMessage = formatUploadError(error);
// Remove compression ID from controller on error
if (taskId) {
controller?.removeActiveCompression(taskId);
}
// Check if this was a cancellation
if (controller?.isCancelled() || errorMessage.includes('cancelled')) {
results.push({ fileName: folderName, success: false, cancelled: true });
} else {
results.push({ fileName: folderName, success: false, error: errorMessage });
}
}
}
return results;
}

View File

@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UploadController } from "./uploadController";
test("cancelling an upload aborts an active pathless-file staging job", async () => {
const calls: string[] = [];
const controller = new UploadController();
controller.setBridge({
mkdirSftp: async () => {},
cancelStagedUploadFile: async (transferId) => { calls.push(`stage:${transferId}`); },
cancelTransfer: async (transferId) => { calls.push(`transfer:${transferId}`); },
});
controller.addActiveTransfer("upload-1");
await controller.cancel();
assert.deepEqual(calls, ["stage:upload-1", "transfer:upload-1"]);
});

163
lib/uploadController.ts Normal file
View File

@@ -0,0 +1,163 @@
import type { UploadBridge } from "./uploadService.types";
export class UploadController {
private cancelled = false;
private activeFileTransferIds = new Set<string>();
private activeCompressionIds = new Set<string>();
private currentTransferId = "";
private bridge: UploadBridge | null = null;
private cancelListeners = new Set<() => void>();
/**
* Register a listener fired as soon as cancel() is requested (before async
* cleanup). Used to abort in-flight local tree scans.
*/
addCancelListener(listener: () => void): () => void {
this.cancelListeners.add(listener);
return () => {
this.cancelListeners.delete(listener);
};
}
/**
* Cancel all active uploads
*/
async cancel(): Promise<void> {
this.cancelled = true;
for (const listener of Array.from(this.cancelListeners)) {
try {
listener();
} catch {
// Ignore listener errors so cancel still drains transfers.
}
}
// Cancel all active compressed uploads
const activeCompressionIds = Array.from(this.activeCompressionIds);
for (const compressionId of activeCompressionIds) {
try {
// Import and call cancelCompressedUpload
const { cancelCompressedUpload } = await import('../infrastructure/services/compressUploadService');
await cancelCompressedUpload(compressionId);
} catch {
// Ignore cancel errors
}
}
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
if (this.bridge?.cancelStagedUploadFile) {
await this.bridge.cancelStagedUploadFile(transferId);
}
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
} catch {
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(this.currentTransferId);
}
} catch {
// Ignore cancel errors
}
}
}
/**
* Check if upload was cancelled
*/
isCancelled(): boolean {
return this.cancelled;
}
/**
* Get all active transfer IDs
*/
getActiveTransferIds(): string[] {
const ids = Array.from(this.activeFileTransferIds);
if (this.currentTransferId && !ids.includes(this.currentTransferId)) {
ids.push(this.currentTransferId);
}
// Also include compression IDs
const compressionIds = Array.from(this.activeCompressionIds);
return [...ids, ...compressionIds];
}
/**
* Reset controller state for a brand-new upload session.
* Prefer prepareForEntries when the controller already owns an external drop
* (scan cancel listeners must stay attached until the drop settles).
*/
reset(): void {
this.cancelled = false;
this.cancelListeners.clear();
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
this.currentTransferId = "";
}
/**
* Soft prepare for entry upload without clearing cancel latches/listeners.
* External drop flows attach scan/conflict cancel listeners before entries
* are ready; a full reset would leave the scanning row uncancelable.
*/
prepareForEntries(): void {
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
this.currentTransferId = "";
}
/**
* Set the bridge for cancellation
*/
setBridge(bridge: UploadBridge): void {
this.bridge = bridge;
}
/**
* Track a file transfer ID
*/
addActiveTransfer(id: string): void {
this.activeFileTransferIds.add(id);
this.currentTransferId = id;
}
/**
* Remove a tracked file transfer ID
*/
removeActiveTransfer(id: string): void {
this.activeFileTransferIds.delete(id);
if (this.currentTransferId === id) {
this.currentTransferId = "";
}
}
/**
* Clear current transfer ID
*/
clearCurrentTransfer(): void {
this.currentTransferId = "";
}
/**
* Track a compression ID
*/
addActiveCompression(id: string): void {
this.activeCompressionIds.add(id);
}
/**
* Remove a tracked compression ID
*/
removeActiveCompression(id: string): void {
this.activeCompressionIds.delete(id);
}
}

1090
lib/uploadService.ts Normal file

File diff suppressed because it is too large Load Diff

146
lib/uploadService.types.ts Normal file
View File

@@ -0,0 +1,146 @@
export interface UploadProgress {
transferred: number;
total: number;
speed: number;
/** Percentage (0-100) */
percent: number;
phase?: import('../domain/models/sftp').TransferPhase;
/** Contiguous durable offset from the transfer bridge (may lag transferred). */
checkpointBytes?: number;
sourceFingerprint?: string;
resumable?: boolean;
pauseUnavailableReason?: string;
}
export interface UploadTaskInfo {
id: string;
fileName: string;
/** Display name for bundled tasks (e.g., "folder (5 files)") */
displayName: string;
isDirectory: boolean;
progressMode?: 'bytes' | 'files';
parentTaskId?: string;
totalBytes: number;
transferredBytes: number;
speed: number;
fileCount: number;
completedCount: number;
sourcePath?: string;
/** Background job API used to control this task after its page closes. */
controlKind?: 'stream' | 'compressed-upload';
}
export interface UploadResult {
fileName: string;
success: boolean;
error?: string;
cancelled?: boolean;
}
export interface UploadCallbacks {
/** Called when a new task is created (for bundled folders or standalone files) */
onTaskCreated?: (task: UploadTaskInfo) => void;
/** Called when task progress is updated */
onTaskProgress?: (taskId: string, progress: UploadProgress) => void;
/** Called when a task is completed */
onTaskCompleted?: (taskId: string, totalBytes: number) => void;
/** Called when a task fails */
onTaskFailed?: (taskId: string, error: string) => void;
/** Called when a task is cancelled */
onTaskCancelled?: (taskId: string) => void;
/** Called when scanning starts (for showing placeholder) */
onScanningStart?: (taskId: string, info?: { label?: string }) => void;
/** Live scan counters (file/dir totals) while enumerating a drop */
onScanningProgress?: (taskId: string, progress: {
fileCount: number;
directoryCount: number;
entryCount: number;
label?: string;
}) => void;
/** Called when scanning ends */
onScanningEnd?: (taskId: string) => void;
/** Called when task name needs to be updated (for phase changes) */
onTaskNameUpdate?: (taskId: string, newName: string) => void;
}
export interface UploadBridge {
/** Main-process transfer events own file progress and terminal lifecycle. */
managesTransferLifecycle?: boolean;
writeLocalFile?: (path: string, data: ArrayBuffer) => Promise<void>;
mkdirLocal?: (path: string) => Promise<void>;
statLocal?: (path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
/** No-follow local metadata so Replace can unlink symlinks before writeLocalFile. */
lstatLocal?: (path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
deleteLocalFile?: (path: string, expectedType?: 'file' | 'directory' | 'symlink') => Promise<void>;
stageUploadFile?: (file: File, taskId: string) => Promise<string>;
cancelStagedUploadFile?: (taskId: string) => Promise<unknown>;
deleteTempFile?: (path: string) => Promise<unknown>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
/** Followed remote metadata — resume / sizing must use target bytes, not the link node. */
statSftp?: (sftpId: string, path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
/** No-follow remote metadata so Replace can unlink symlinks before in-place upload. */
lstatSftp?: (sftpId: string, path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
deleteSftp?: (sftpId: string, path: string, expectedType?: 'file' | 'directory' | 'symlink') => Promise<void>;
/** Stream transfer using local file path (avoids loading file into memory) */
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
sourceHostId?: string;
targetHostId?: string;
totalBytes?: number;
sourceEncoding?: import('../domain/models/sftp').SftpFilenameEncoding;
targetEncoding?: import('../domain/models/sftp').SftpFilenameEncoding;
sameHost?: boolean;
resumable?: boolean;
checkpointBytes?: number;
resumeStage?: 'direct' | 'download' | 'upload';
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
sourceFingerprint?: string;
lifecycleEpoch?: number;
lifecycleState?: 'queued' | 'pausing' | 'paused' | 'transferring';
pauseUnavailableReason?: string;
globalConcurrency?: number;
skipAdmission?: boolean;
}
) => Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
cancelTransfer?: (transferId: string) => Promise<void>;
}
export interface UploadConfig {
/** Target directory path */
targetPath: string;
/** SFTP session ID (null for local) */
sftpId: string | null;
/** Stable target host ID, used to apply the concurrency limit per server. */
targetHostId?: string;
/** Maximum number of files this upload may admit at once. */
fileTransferConcurrency?: number;
/** Is this a local file system upload? */
isLocal: boolean;
/** The bridge for file operations */
bridge: UploadBridge;
/** Path joining function */
joinPath: (base: string, name: string) => string;
/** Callbacks for progress updates */
callbacks?: UploadCallbacks;
/** Use compressed upload for folders (requires tar on both local and remote) */
useCompressedUpload?: boolean;
resolveConflict?: (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}) => Promise<'stop' | 'skip' | 'replace' | 'duplicate' | 'merge'>;
}

View File

@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import { matchesSearchQuery } from "./searchMatcher";
import { buildQuickSwitcherShells, resolveShellSetting } from "./useDiscoveredShells";
const DISCOVERED: DiscoveredShell[] = [
{ id: "git-bash", name: "Git Bash", command: "C:\\Git\\bin\\bash.exe", args: ["--login", "-i"], icon: "git-bash" },
];
const WINDOWS_SHELLS: DiscoveredShell[] = [
{ id: "cmd", name: "CMD", command: "cmd.exe", args: [], icon: "cmd" },
{ id: "powershell", name: "Windows PowerShell", command: "powershell.exe", args: ["-NoLogo"], icon: "powershell", isDefault: true },
{ id: "pwsh", name: "PowerShell 7", command: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", args: ["-NoLogo"], icon: "pwsh" },
];
test("resolveShellSetting returns null for empty value", () => {
assert.equal(resolveShellSetting("", DISCOVERED), null);
});
test("resolveShellSetting passes custom args through for a custom path", () => {
const resolved = resolveShellSetting("C:\\msys64\\usr\\bin\\bash.exe", DISCOVERED, ["--login", "-i"]);
assert.equal(resolved?.command, "C:\\msys64\\usr\\bin\\bash.exe");
assert.deepEqual(resolved?.args, ["--login", "-i"]);
});
test("resolveShellSetting omits args when custom args are empty (preserves bridge fallback)", () => {
const resolved = resolveShellSetting("/usr/local/bin/fish", DISCOVERED, []);
assert.equal(resolved?.command, "/usr/local/bin/fish");
assert.equal(resolved?.args, undefined);
});
test("resolveShellSetting omits args when no custom args are given", () => {
const resolved = resolveShellSetting("/usr/local/bin/fish", DISCOVERED);
assert.equal(resolved?.command, "/usr/local/bin/fish");
assert.equal(resolved?.args, undefined);
});
test("resolveShellSetting uses discovered shell args when value matches and no custom args are given", () => {
const resolved = resolveShellSetting("git-bash", DISCOVERED);
assert.equal(resolved?.command, "C:\\Git\\bin\\bash.exe");
assert.deepEqual(resolved?.args, ["--login", "-i"]);
});
test("resolveShellSetting prefers explicit custom args when value collides with a discovered shell id", () => {
const resolved = resolveShellSetting("git-bash", DISCOVERED, ["--private"]);
assert.equal(resolved?.command, "C:\\Git\\bin\\bash.exe");
assert.deepEqual(resolved?.args, ["--private"]);
});
test("buildQuickSwitcherShells keeps discovered defaults when no local shell is configured", () => {
const shells = buildQuickSwitcherShells(WINDOWS_SHELLS, "");
assert.equal(shells.find((shell) => shell.id === "powershell")?.isDefault, true);
assert.equal(shells.find((shell) => shell.id === "pwsh")?.isDefault, undefined);
});
test("buildQuickSwitcherShells marks a configured discovered shell as default", () => {
const shells = buildQuickSwitcherShells(WINDOWS_SHELLS, "pwsh");
const pwsh = shells.find((shell) => shell.id === "pwsh");
assert.equal(pwsh?.isDefault, true);
assert.equal(pwsh?.command, "C:\\Program Files\\PowerShell\\7\\pwsh.exe");
assert.deepEqual(pwsh?.args, ["-NoLogo"]);
assert.equal(shells.find((shell) => shell.id === "powershell")?.isDefault, false);
});
test("buildQuickSwitcherShells maps a custom pwsh.exe setting onto the PowerShell quick switch entry", () => {
const shells = buildQuickSwitcherShells(
WINDOWS_SHELLS.filter((shell) => shell.id !== "pwsh"),
"pwsh.exe",
);
const powershell = shells.find((shell) => shell.id === "powershell");
assert.equal(powershell?.isDefault, true);
assert.equal(powershell?.name, "PowerShell 7");
assert.equal(powershell?.command, "pwsh.exe");
assert.equal(powershell?.icon, "pwsh");
assert.equal(shells.find((shell) => shell.id === "cmd")?.isDefault, false);
});
test("custom quick switch shells remain searchable by executable name", () => {
const shells = buildQuickSwitcherShells(
WINDOWS_SHELLS.filter((shell) => shell.id !== "pwsh"),
"pwsh.exe",
);
const powershell = shells.find((shell) => shell.id === "powershell");
assert.ok(powershell);
assert.equal(matchesSearchQuery("pwsh", powershell.name, powershell.id, powershell.command), true);
});
test("buildQuickSwitcherShells matches custom shell paths by full command before basename fallback", () => {
const shells = buildQuickSwitcherShells([
{ id: "bash-system", name: "Bash (/bin/bash)", command: "/bin/bash", args: ["-l"], icon: "bash", isDefault: true },
{ id: "bash-homebrew", name: "Bash (/opt/homebrew/bin/bash)", command: "/opt/homebrew/bin/bash", args: ["-l"], icon: "bash" },
], "/opt/homebrew/bin/bash");
assert.equal(shells.find((shell) => shell.id === "bash-system")?.isDefault, false);
assert.equal(shells.find((shell) => shell.id === "bash-homebrew")?.isDefault, true);
});

210
lib/useDiscoveredShells.ts Normal file
View File

@@ -0,0 +1,210 @@
import { useEffect, useState } from "react";
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
let shellCache: DiscoveredShell[] | null = null;
let shellPromise: Promise<DiscoveredShell[]> | null = null;
/**
* Resolve discovered shells, awaiting the in-flight discovery when needed.
* Safe to call from cold-start paths that must not create a session before
* WSL/Git-Bash IDs can be classified and labeled.
*/
export async function ensureDiscoveredShells(): Promise<DiscoveredShell[]> {
if (shellCache) return shellCache;
const bridge = netcattyBridge.get();
if (!bridge?.discoverShells) return [];
if (!shellPromise) {
shellPromise = bridge.discoverShells().then((result) => {
shellCache = result;
return result;
}).catch((err) => {
console.warn("Failed to discover shells:", err);
shellPromise = null;
throw err;
});
}
try {
return await shellPromise;
} catch {
return shellCache ?? [];
}
}
export function useDiscoveredShells(): DiscoveredShell[] {
const [shells, setShells] = useState<DiscoveredShell[]>(shellCache ?? []);
useEffect(() => {
if (shellCache) {
setShells(shellCache);
return;
}
let cancelled = false;
void ensureDiscoveredShells().then((result) => {
if (!cancelled) setShells(result);
});
return () => {
cancelled = true;
};
}, []);
return shells;
}
/**
* Resolve a localShell setting value to shell command and args.
* The value can be a discovered shell id (e.g., "wsl-ubuntu", "pwsh")
* or a custom path/command (e.g., "/usr/local/bin/fish" or "fish").
* `customArgs` are the user-configured launch args (e.g. ["--login", "-i"] for
* msys2 bash). When present, they take precedence over discovered shell defaults
* so custom commands like "bash" or "fish" can collide with discovered IDs
* without losing the user's explicit args. Returns { command, args } or null
* when discovery hasn't loaded yet and the value might be a shell ID that can't
* be resolved yet.
*/
export function resolveShellSetting(
localShell: string,
discoveredShells: DiscoveredShell[],
customArgs?: string[]
): { command: string; args?: string[] } | null {
if (!localShell) return null;
// Try to match as a discovered shell id. Discovered shells provide their own
// args (e.g. WSL "-d Ubuntu"), unless the user explicitly configured custom
// args for a command/path that happens to share the same value as an ID.
const shell = discoveredShells.find(s => s.id === localShell);
if (shell) {
return { command: shell.command, args: customArgs?.length ? customArgs : shell.args };
}
// No ID match — treat as a custom shell path/command and pass through.
// This handles both custom executables (e.g., "/usr/local/bin/fish", "pwsh-preview")
// and stale/synced IDs that no longer exist on this machine (graceful fallback
// to whatever the OS resolves the name to, or a spawn error the user can see).
// Omit args when none are configured so the bridge's getLocalShellArgs fallback
// (login flags, PowerShell -NoLogo) still applies — only override it when the
// user has explicitly set launch args (#1221).
return { command: localShell, args: customArgs?.length ? customArgs : undefined };
}
const CONFIGURED_LOCAL_SHELL_ID = "__configured-local-shell__";
function getShellBaseName(command: string | undefined): string {
const parts = String(command || "").trim().split(/[\\/]/);
return (parts[parts.length - 1] || "").toLowerCase();
}
function normalizeShellCommand(command: string | undefined): string {
return String(command || "").trim().replace(/\\/g, "/").toLowerCase();
}
function getFriendlyCustomShell(shell: string): Pick<DiscoveredShell, "name" | "icon"> {
const base = getShellBaseName(shell);
const stem = base.endsWith(".exe") ? base.slice(0, -4) : base;
switch (stem) {
case "pwsh":
return { name: "PowerShell 7", icon: "pwsh" };
case "powershell":
return { name: "Windows PowerShell", icon: "powershell" };
case "cmd":
return { name: "CMD", icon: "cmd" };
case "bash":
return { name: "Bash", icon: "bash" };
case "zsh":
return { name: "Zsh", icon: "zsh" };
case "fish":
return { name: "Fish", icon: "fish" };
case "nu":
return { name: "Nushell", icon: "nushell" };
default:
return { name: shell || "Local Terminal", icon: "terminal" };
}
}
function findConfiguredShellTarget(
discoveredShells: DiscoveredShell[],
localShell: string,
resolvedCommand: string,
): DiscoveredShell | undefined {
const matchedById = discoveredShells.find((shell) => shell.id === localShell);
if (matchedById) return matchedById;
const configuredCommand = normalizeShellCommand(resolvedCommand || localShell);
const matchedByCommand = discoveredShells.find((shell) => (
normalizeShellCommand(shell.command) === configuredCommand
));
if (matchedByCommand) return matchedByCommand;
const configuredBase = getShellBaseName(resolvedCommand || localShell);
if (configuredBase === "pwsh.exe" || configuredBase === "pwsh") {
return (
discoveredShells.find((shell) => shell.id === "pwsh") ??
discoveredShells.find((shell) => shell.id === "powershell")
);
}
if (configuredBase === "powershell.exe" || configuredBase === "powershell") {
return (
discoveredShells.find((shell) => shell.id === "powershell") ??
discoveredShells.find((shell) => shell.id === "pwsh")
);
}
return undefined;
}
export function buildQuickSwitcherShells(
discoveredShells: DiscoveredShell[],
localShell: string,
customArgs?: string[],
): DiscoveredShell[] {
const configured = resolveShellSetting(localShell, discoveredShells, customArgs);
if (!configured) return discoveredShells;
const target = findConfiguredShellTarget(discoveredShells, localShell, configured.command);
const friendly = getFriendlyCustomShell(configured.command || localShell);
const configuredShell: DiscoveredShell = {
...(target ?? {
id: CONFIGURED_LOCAL_SHELL_ID,
args: undefined,
}),
name: target && target.id === localShell ? target.name : friendly.name,
command: configured.command,
args: configured.args,
icon: target && target.id === localShell ? target.icon : friendly.icon,
isDefault: true,
};
if (!target) {
return [
configuredShell,
...discoveredShells.map((shell) => ({ ...shell, isDefault: false })),
];
}
return discoveredShells.map((shell) => (
shell.id === target.id
? configuredShell
: { ...shell, isDefault: false }
));
}
const DISTRO_ICONS = new Set([
"ubuntu", "debian", "kali", "alpine", "opensuse",
"fedora", "arch", "oracle", "linux",
]);
export function getShellIconPath(iconId: string): string {
if (DISTRO_ICONS.has(iconId)) {
return `/distro/${iconId}.svg`;
}
return `/shells/${iconId}.svg`;
}
/** Distro icons are monochrome black and need `dark:invert` in dark mode */
export function isMonochromeShellIcon(iconId: string): boolean {
return DISTRO_ICONS.has(iconId);
}

80
lib/useRenderTracker.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useRef } from "react";
import { logger } from "./logger";
// Set to true to enable render tracking logs (for debugging only)
const DEBUG_RENDER_TRACKING = false;
/**
* 追踪组件渲染次数和原因
* 在开发环境下帮助识别不必要的重渲染
*
* @param componentName 组件名称
* @param props 当前 props用于比较变化
* @param enabled 是否启用追踪,默认 false需要调试时手动启用
*/
export function useRenderTracker(
componentName: string,
props: Record<string, unknown>,
enabled: boolean = DEBUG_RENDER_TRACKING
): void {
const renderCountRef = useRef(0);
const prevPropsRef = useRef<Record<string, unknown>>({});
renderCountRef.current += 1;
if (!enabled) return;
const renderCount = renderCountRef.current;
const prevProps = prevPropsRef.current;
// 找出变化的 props
const changedProps: string[] = [];
const allKeys = new Set([...Object.keys(props), ...Object.keys(prevProps)]);
for (const key of allKeys) {
if (prevProps[key] !== props[key]) {
changedProps.push(key);
}
}
// 只在有变化时打印(减少日志噪音)
if (renderCount === 1) {
logger.info(`[Render] ${componentName} - 首次渲染`);
} else if (changedProps.length > 0) {
logger.info(`[Render] ${componentName} - 第${renderCount}次渲染`, {
changedProps,
details: changedProps.reduce((acc, key) => {
acc[key] = {
prev: summarizeValue(prevProps[key]),
curr: summarizeValue(props[key]),
};
return acc;
}, {} as Record<string, { prev: string; curr: string }>),
});
}
// 不再打印 "props未变化" 的警告 - 这是正常的 React 行为
// 更新 prevProps
prevPropsRef.current = { ...props };
}
/**
* 简化值的显示,避免日志过长
*/
function summarizeValue(value: unknown): string {
if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "function") return `fn:${value.name || "anonymous"}`;
if (typeof value === "object") {
if (Array.isArray(value)) return `Array(${value.length})`;
const keys = Object.keys(value);
if (keys.length <= 3) {
return `{${keys.join(", ")}}`;
}
return `Object(${keys.length} keys)`;
}
if (typeof value === "string" && value.length > 30) {
return `"${value.slice(0, 30)}..."`;
}
return String(value);
}

36
lib/utils.ts Normal file
View File

@@ -0,0 +1,36 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Normalize line endings to LF (Unix style).
* Converts CRLF (Windows) and standalone CR (old Mac) to LF.
* Used for clipboard paste operations in terminal to avoid extra blank lines.
*/
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Wrap text in bracketed paste escape sequences.
* When a terminal application enables bracketed paste mode (CSI ?2004h),
* pasted text should be wrapped so the application can distinguish paste
* from typed input (e.g. vim disables autoindent during paste).
*/
export function wrapBracketedPaste(text: string): string {
return `\x1b[200~${text}\x1b[201~`;
}
/**
* Detect if the current platform is macOS.
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.
*/
export function isMacPlatform(): boolean {
if (typeof navigator !== 'undefined') {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
}
return false;
}

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DropEntry } from "./sftpFileUtils";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
ZMODEM_DEFAULT_RZ_UPLOAD_COMMAND,
} from "./zmodemDragDrop";
test("ZMODEM drag-drop rz command overwrites existing remote files", () => {
// lrzsz rz defaults to protect mode and refuses same-named files unless -y.
assert.match(ZMODEM_DEFAULT_RZ_UPLOAD_COMMAND, /\brz\s+-y\b/);
assert.match(ZMODEM_DEFAULT_RZ_UPLOAD_COMMAND, /\r$/);
const command = buildZmodemDragDropUploadCommand("rz-token");
assert.match(command, /\bexec rz -y\b/);
assert.match(command, /NetcattyRzMissing=rz-token/);
assert.match(command, /\r$/);
});
test("ZMODEM drops use reconstructed paths without buffering the file", async () => {
let buffered = false;
const entry: DropEntry = {
file: {
name: "large.bin",
arrayBuffer: async () => {
buffered = true;
return new ArrayBuffer(0);
},
} as File,
localPath: "/tmp/large.bin",
relativePath: "large.bin",
isDirectory: false,
};
const files = await buildZmodemDragDropFiles([entry]);
assert.deepEqual(files, [{
path: "/tmp/large.bin",
name: "large.bin",
remoteName: "large.bin",
}]);
assert.equal(buffered, false);
});
test("ZMODEM drops accept path-only files from native folder scans", async () => {
const files = await buildZmodemDragDropFiles([{
file: null,
localPath: "/tmp/project/src/main.ts",
relativePath: "project/src/main.ts",
isDirectory: false,
}]);
assert.deepEqual(files, [{
path: "/tmp/project/src/main.ts",
name: "main.ts",
remoteName: "main.ts",
}]);
});

97
lib/zmodemDragDrop.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { DropEntry } from "./sftpFileUtils";
import { getDropEntryLocalPath } from "./sftpFileUtils";
import type { Host } from "../types";
const ZMODEM_RZ_MISSING_MARKER_PREFIX = "\x1b]1337;NetcattyRzMissing=";
const ZMODEM_RZ_MISSING_MARKER_SUFFIX = "\x07";
/**
* Default PTY command for drag-drop ZMODEM upload.
* lrzsz `rz` defaults to protect mode and will not replace an existing
* same-named file; `-y` / `--overwrite` is required (issue #2863).
*/
export const ZMODEM_DEFAULT_RZ_UPLOAD_COMMAND = "rz -y\r";
export type ZmodemDragDropFile = {
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
};
export function supportsZmodemTerminalDragDrop(
host: Host,
isNetworkDevice = false,
): boolean {
if (host.protocol === "local" || isNetworkDevice) return false;
if (host.moshEnabled || host.etEnabled) return true;
return (
host.protocol === "ssh" ||
host.protocol === "telnet" ||
host.protocol === "serial"
);
}
export function supportsZmodemDragDropSftpFallback(host: Host): boolean {
return host.protocol === "ssh" || Boolean(host.moshEnabled || host.etEnabled);
}
export function getZmodemRemoteName(relativePath: string, fallbackName: string): string {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!normalized) return fallbackName;
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || fallbackName;
}
function quotePosixShellArg(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function createZmodemRzMissingToken(): string {
return `rz-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function buildZmodemDragDropUploadCommand(rzMissingToken: string): string {
const markerFormat = `\\033]1337;NetcattyRzMissing=${rzMissingToken}\\007`;
const script = `if command -v rz >/dev/null 2>&1; then exec rz -y; else printf ${quotePosixShellArg(markerFormat)}; fi`;
return `sh -lc ${quotePosixShellArg(script)}\r`;
}
export function containsZmodemRzMissingMarker(chunk: string, rzMissingToken: string): boolean {
return chunk.includes(`${ZMODEM_RZ_MISSING_MARKER_PREFIX}${rzMissingToken}${ZMODEM_RZ_MISSING_MARKER_SUFFIX}`);
}
export async function buildZmodemDragDropFiles(
dropEntries: DropEntry[],
): Promise<ZmodemDragDropFile[]> {
const files: ZmodemDragDropFile[] = [];
for (const entry of dropEntries) {
if (entry.isDirectory) continue;
const fileName = entry.file?.name
|| entry.relativePath.replace(/\\/g, "/").split("/").pop()
|| entry.relativePath;
const remoteName = getZmodemRemoteName(entry.relativePath, fileName);
const localPath = getDropEntryLocalPath(entry);
if (localPath) {
files.push({
path: localPath,
name: fileName,
remoteName,
});
continue;
}
if (!entry.file) continue;
const data = await entry.file.arrayBuffer();
files.push({
name: fileName,
remoteName,
data,
});
}
return files;
}