[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
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:
50
lib/commandBlocklist.cjs
Normal file
50
lib/commandBlocklist.cjs
Normal 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
37
lib/commandBlocklist.json
Normal 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
23
lib/customCss.test.ts
Normal 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
14
lib/customCss.ts
Normal 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;
|
||||
}
|
||||
224
lib/fontAvailability.test.ts
Normal file
224
lib/fontAvailability.test.ts
Normal 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
189
lib/fontAvailability.ts
Normal 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();
|
||||
}
|
||||
93
lib/fontWeightAvailability.test.ts
Normal file
93
lib/fontWeightAvailability.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
104
lib/fontWeightAvailability.ts
Normal file
104
lib/fontWeightAvailability.ts
Normal 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
136
lib/localFonts.test.ts
Normal 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
222
lib/localFonts.ts
Normal 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
65
lib/localShell.cjs
Normal 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
37
lib/localShell.test.ts
Normal 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
41
lib/localShell.ts
Normal 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
7
lib/localShellRules.json
Normal 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
24
lib/logger.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
||||
815
lib/progressiveFolderUpload.test.ts
Normal file
815
lib/progressiveFolderUpload.test.ts
Normal 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);
|
||||
});
|
||||
689
lib/progressiveFolderUpload.ts
Normal file
689
lib/progressiveFolderUpload.ts
Normal 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
59
lib/readTextFile.test.ts
Normal 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
48
lib/readTextFile.ts
Normal 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
19
lib/regexSafety.ts
Normal 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
369
lib/searchMatcher.ts
Normal 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
145
lib/sftpFileUtils.test.ts
Normal 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
812
lib/sftpFileUtils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
66
lib/tabInteractions.test.ts
Normal file
66
lib/tabInteractions.test.ts
Normal 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
34
lib/tabInteractions.ts
Normal 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
110
lib/textZip.ts
Normal 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",
|
||||
});
|
||||
};
|
||||
95
lib/uploadCompressed.test.ts
Normal file
95
lib/uploadCompressed.test.ts
Normal 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
187
lib/uploadCompressed.ts
Normal 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;
|
||||
}
|
||||
17
lib/uploadController.test.ts
Normal file
17
lib/uploadController.test.ts
Normal 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
163
lib/uploadController.ts
Normal 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
1090
lib/uploadService.ts
Normal file
File diff suppressed because it is too large
Load Diff
146
lib/uploadService.types.ts
Normal file
146
lib/uploadService.types.ts
Normal 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'>;
|
||||
}
|
||||
97
lib/useDiscoveredShells.test.ts
Normal file
97
lib/useDiscoveredShells.test.ts
Normal 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
210
lib/useDiscoveredShells.ts
Normal 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
80
lib/useRenderTracker.ts
Normal 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
36
lib/utils.ts
Normal 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;
|
||||
}
|
||||
60
lib/zmodemDragDrop.test.ts
Normal file
60
lib/zmodemDragDrop.test.ts
Normal 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
97
lib/zmodemDragDrop.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user