Merge pull request 'fixing issues reported by SQ and adding test coverage.' (#9) from sq_fixes into master
CI / TypeScript Lint + Typecheck (push) Successful in 22s
CI / Rust Format (push) Successful in 28s
CI / Rust Tests + Coverage (push) Successful in 38s
CI / TypeScript Tests + Coverage (push) Successful in 46s
Release Builds / macOS Apple Silicon Electron Build (push) Successful in 1m28s
CI / Clippy (SARIF) (push) Successful in 1m52s
Release Builds / Windows Electron Build (push) Successful in 2m53s
CI / Electron Release Build (push) Successful in 2m17s
Release Builds / Publish Gitea Release (push) Has been skipped
CI / SonarQube (push) Successful in 1m8s
CI / E2E Tests (Playwright + Electron) (push) Successful in 2m53s
CI / Dependency-Track (BOM) (push) Failing after 10m32s

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-05-10 16:55:33 -04:00
38 changed files with 2184 additions and 309 deletions
+116 -1
View File
@@ -294,6 +294,7 @@ jobs:
name: SonarQube
runs-on: ubuntu-latest
needs: [clippy, test, ts-test]
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
@@ -307,19 +308,133 @@ jobs:
- uses: actions/download-artifact@v3
with:
name: test-reports
path: .
path: target
- uses: actions/download-artifact@v3
with:
name: ts-test-reports
path: ui-reports
- name: Normalize and validate Sonar reports
run: |
set -euo pipefail
mkdir -p target
if [ ! -s target/lcov.info ]; then
for candidate in target/target/lcov.info lcov.info; do
if [ -s "$candidate" ]; then
cp "$candidate" target/lcov.info
break
fi
done
fi
for report in target/lcov.info target/clippy-report.sarif ui-reports/coverage/lcov.info; do
if [ ! -s "$report" ]; then
echo "::error::Missing required Sonar report: $report"
echo "Downloaded report candidates:"
find . -maxdepth 4 \( -name 'lcov.info' -o -name '*.sarif' \) -print
exit 1
fi
ls -lh "$report"
done
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube Quality Gate Diagnostics
if: always()
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
run: |
python3 - <<'PY'
import base64
import json
import os
import sys
import time
import urllib.request
report_task = ".scannerwork/report-task.txt"
if not os.path.exists(report_task):
print(f"{report_task} was not produced; skipping Quality Gate diagnostics")
sys.exit(0)
props = {}
with open(report_task, encoding="utf-8") as handle:
for line in handle:
if "=" in line and not line.lstrip().startswith("#"):
key, value = line.rstrip("\n").split("=", 1)
props[key] = value
token = os.environ.get("SONAR_TOKEN", "")
host = os.environ.get("SONAR_HOST_URL", "").rstrip("/")
if not token or not host:
print("SONAR_TOKEN or SONAR_HOST_URL is missing; skipping Quality Gate diagnostics")
sys.exit(0)
auth = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii")
def get_json(url):
request = urllib.request.Request(url, headers={"Authorization": f"Basic {auth}"})
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode("utf-8"))
ce_task_url = props.get("ceTaskUrl")
if not ce_task_url:
print("ceTaskUrl missing from report-task.txt; skipping Quality Gate diagnostics")
sys.exit(0)
try:
analysis_id = None
for _ in range(45):
task = get_json(ce_task_url)["task"]
status = task["status"]
if status == "SUCCESS":
analysis_id = task.get("analysisId")
break
if status in {"FAILED", "CANCELED"}:
print(f"Sonar compute engine task ended with status {status}")
sys.exit(0)
time.sleep(2)
if not analysis_id:
print("Timed out waiting for Sonar compute engine task; Quality Gate action will enforce the result")
sys.exit(0)
gate = get_json(f"{host}/api/qualitygates/project_status?analysisId={analysis_id}")["projectStatus"]
print(f"Quality Gate: {gate['status']}")
for condition in gate.get("conditions", []):
if condition.get("status") != "OK":
print(
"{metric}: {status} actual={actual} threshold={threshold}".format(
metric=condition.get("metricKey"),
status=condition.get("status"),
actual=condition.get("actualValue"),
threshold=condition.get("errorThreshold"),
)
)
issues_url = f"{host}/api/issues/search?componentKeys=ByteDraft&resolved=false&inNewCodePeriod=true&ps=20"
issues = get_json(issues_url).get("issues", [])
for issue in issues:
print(
"Issue: {severity} {rule} {component}:{line} {message}".format(
severity=issue.get("severity"),
rule=issue.get("rule"),
component=issue.get("component"),
line=issue.get("line", "?"),
message=issue.get("message"),
)
)
except Exception as exc:
print(f"Quality Gate diagnostics were unavailable: {exc}")
sys.exit(0)
PY
- name: SonarQube Quality Gate
uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
+20 -25
View File
@@ -1,33 +1,28 @@
sonar.projectKey=ByteDraft
sonar.projectName=ByteDraft
sonar.projectVersion=1.0
sonar.sourceEncoding=UTF-8
sonar.sources=crates
sonar.exclusions=**/target/**,**/*.lock
sonar.inclusions=**/*.rs
# Clippy issues imported via SARIF (generated in CI clippy job → artifact).
# Do not run the Clippy sensor here: the scanner image has no Rust toolchain (cargo).
sonar.rust.clippy.enabled=false
sonar.sarifReportPaths=target/clippy-report.sarif
# Rust coverage: native LCOV import (Sonar Rust analyzer). Do not use sonar.coverageReportPaths for .rs —
# the Generic Coverage sensor fails on our converted XML on SQ 26.x; see test-coverage-parameters (Rust).
sonar.rust.lcov.reportPaths=target/lcov.info
# Generic test execution import is disabled: SonarQube 26.4 + JS bridge intermittently fails while reading
# sonar-test-execution.xml (InterruptedException in BridgeServerImpl.isAlive, then a misleading parse error).
# CI still generates test-results/sonar-test-execution.xml (Vitest) and target/nextest/ci/... (Rust) for debugging.
sonar.scm.provider=git
# ── TypeScript frontend ───────────────────────────────────────────
# Include ui/src alongside crates for analysis
sonar.sources=crates,ui/src
# Source and test layout
sonar.sources=crates/byte_draft/src,crates/byte_draft_desktop/src,ui/src
sonar.tests=crates/byte_draft/tests,ui/tests
sonar.exclusions=**/target/**,**/*.lock,ui/node_modules/**,ui/dist/**,ui/coverage/**
sonar.inclusions=**/*.rs,**/*.ts,**/*.tsx
# TypeScript LCOV coverage from Vitest (downloaded to ui-reports/ in CI)
# Local: Vitest outputs to ../coverage relative to ui/ → workspace-root/coverage/lcov.info
# CI: Artifact downloaded to ui-reports/ preserving structure → ui-reports/coverage/lcov.info
# SonarQube will use whichever path exists
# Clippy issues imported via SARIF generated by CI or scripts/sonar-local.ps1.
# The scanner image does not run Cargo, so keep the built-in Clippy sensor off.
sonar.rust.clippy.enabled=false
sonar.sarifReportPaths=target/clippy-report.sarif
# Rust LCOV from cargo llvm-cov.
sonar.rust.lcov.reportPaths=target/lcov.info
# Generic Test Execution stays disabled until the SQ 26.x parser/bridge issue is resolved.
# CI and scripts/sonar-local.ps1 still generate XML reports for debugging.
# sonar.testExecutionReportPaths=target/nextest/ci/sonar-test-execution.xml
sonar.scm.provider=git
# TypeScript LCOV from Vitest.
# Local runs write coverage/lcov.info; CI downloads the artifact to ui-reports/coverage/lcov.info.
sonar.javascript.lcov.reportPaths=coverage/lcov.info,ui-reports/coverage/lcov.info
+3 -3
View File
@@ -101,7 +101,7 @@ const mockApi: WindowApi = {
/** Merge injected `window.api` (Playwright / Vitest) with defaults. */
function mergedApi(): WindowApi {
const injected = (window as unknown as { api?: Partial<WindowApi> }).api;
const injected = (globalThis as typeof globalThis & { api?: Partial<WindowApi> }).api;
if (!injected) return mockApi;
return { ...mockApi, ...injected } as WindowApi;
}
@@ -110,10 +110,10 @@ function mergedApi(): WindowApi {
* Lazy merge on every access so `window.api` from Playwright init scripts is always
* respected (module evaluation order vs. init script is not guaranteed on all runners).
*/
export const api = new Proxy(mockApi, {
export const api: WindowApi = new Proxy(mockApi, {
get(_, prop: keyof WindowApi) {
const m = mergedApi();
const v = m[prop];
return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(m) : v;
},
}) as WindowApi;
});
@@ -185,8 +185,6 @@ function CommandItem({ command, isSelected, onMouseEnter, onClick, index }: Read
<button
ref={ref}
type="button"
role="option"
aria-selected={isSelected}
className={`w-full text-left px-4 py-2 cursor-pointer flex justify-between items-center ${
isSelected ? 'bg-gray-700' : 'hover:bg-gray-700'
}`}
@@ -334,15 +332,15 @@ export function CommandPalette() {
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 bg-black/50 z-50 border-0 p-0 cursor-default"
className="fixed inset-0 bg-black/50 z-50 border-0 p-0"
aria-label="Close command palette"
onClick={toggleCommandPalette}
/>
{/* Panel */}
<div
<dialog
open
data-testid="command-palette-panel"
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed top-1/4 left-1/2 -translate-x-1/2 w-[600px] max-h-96 bg-theme-surface border border-theme rounded-lg shadow-xl flex flex-col z-50 overflow-hidden text-theme"
@@ -359,7 +357,7 @@ export function CommandPalette() {
/>
{/* Results */}
<div role="listbox" aria-label="Commands" className="overflow-y-auto" id="command-palette-results">
<div className="overflow-y-auto" id="command-palette-results">
{goToLineMode && <GoToLineHint lineNum={lineNum} maxLines={lines} />}
{!goToLineMode && filteredCommands.length === 0 && (
<div className="px-4 py-3 text-gray-400 text-sm">No commands found.</div>
@@ -377,7 +375,7 @@ export function CommandPalette() {
))
)}
</div>
</div>
</dialog>
</>
);
}
+6 -2
View File
@@ -22,6 +22,10 @@ function tabTitle(tab: Tab): string {
return tab.path.split('/').pop() ?? 'Untitled';
}
function isSerializedDockview(layout: object | null): layout is SerializedDockview {
return layout !== null && 'grid' in layout && 'panels' in layout;
}
export function DockContainer() {
const dockApiRef = useRef<DockviewApi | null>(null);
const dockElRef = useRef<HTMLDivElement | null>(null);
@@ -224,11 +228,11 @@ export function DockContainer() {
dockApiRegistry.set(api);
const { tabs: currentTabs, activeTabId: currentActiveId } = useEditorStore.getState();
const savedLayout = useSessionStore.getState().dockLayout as SerializedDockview | null; // object | null narrowed to concrete dockview type
const savedLayout = useSessionStore.getState().dockLayout;
let layoutRestored = false;
if (savedLayout?.panels) {
if (isSerializedDockview(savedLayout)) {
// Only restore if the saved panel IDs exactly match the current store tab IDs.
// A mismatch means the session data was corrupted or the layout is stale.
const layoutIds = new Set(Object.keys(savedLayout.panels));
+7 -5
View File
@@ -1,6 +1,8 @@
import type { IDockviewHeaderActionsProps } from 'dockview';
import { useEditorStore } from '../../state/editorStore';
import type { Tab, ViewMode } from '../../types';
import type { Tab } from '../../types';
type MarkdownMode = 'source' | 'split' | 'preview';
// ── Markdown view selector icons ──────────────────────────────────────────────
@@ -40,16 +42,16 @@ function MarkdownViewSelector() {
const activeTab = tabs.find((t) => t.id === activeTabId && t.language === 'markdown');
if (!activeTab) return null;
const VIEW_KIND_TO_MODE: Record<string, ViewMode> = {
const VIEW_KIND_TO_MODE: Record<string, MarkdownMode> = {
markdown: 'split',
markdownPreviewOnly: 'preview',
};
const currentMode: ViewMode = VIEW_KIND_TO_MODE[activeTab.viewKind ?? ''] ?? 'source';
const currentMode: MarkdownMode = VIEW_KIND_TO_MODE[activeTab.viewKind ?? ''] ?? 'source';
function modeBtn(
label: string,
icon: React.ReactNode,
mode: ViewMode,
mode: MarkdownMode,
viewKind: Tab['viewKind'],
) {
const active = currentMode === mode;
@@ -81,7 +83,7 @@ function MarkdownViewSelector() {
// ── DockRightActions ──────────────────────────────────────────────────────────
export function DockRightActions(_props: IDockviewHeaderActionsProps) {
export function DockRightActions(_props: Readonly<IDockviewHeaderActionsProps>) {
const addTab = useEditorStore((s) => s.addTab);
return (
+15 -56
View File
@@ -1,5 +1,5 @@
import { api } from '../../api';
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor, rectangularSelection, crosshairCursor } from '@codemirror/view';
import { EditorState, Compartment, Extension } from '@codemirror/state';
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
@@ -29,53 +29,9 @@ import { useUiStore } from '../../state/uiStore';
import { useSessionStore } from '../../state/sessionStore';
import { editorViewRegistry } from '../../state/editorViewRegistry';
import { makeLineEndingsPlugin } from '../../extensions/lineEndings';
import { formatDocumentContent, NO_FORMATTER_PLAIN_MSG } from '../../lib/formatDocument';
import { applyFormatOnPaste } from './formatOnPaste';
const ignoreError = () => {};
function findTextTab(id: string) {
return useEditorStore.getState().tabs.find((x) => x.id === id && x.viewKind === 'text');
}
async function applyFormatOnPaste(
tabId: string,
view: EditorView,
snapshot: string,
inFlightRef: React.MutableRefObject<boolean>,
): Promise<void> {
try {
const t = findTextTab(tabId);
if (!t) return;
let lang = t.languageOverride ?? t.language;
if (!t.languageOverride && (!t.path || t.language === 'plaintext')) {
try {
const detected = await api.detectLanguage(t.path ?? '', snapshot);
if (detected && detected !== t.language) {
useEditorStore.getState().updateTab(tabId, { language: detected });
lang = detected;
}
} catch {
// Ignore detection errors; editing should continue uninterrupted.
}
}
if (!useUiStore.getState().formatOnPaste) return;
const formatted = await formatDocumentContent(snapshot, lang);
if (formatted === snapshot) return;
if (view.state.doc.toString() !== snapshot) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: formatted } });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg !== NO_FORMATTER_PLAIN_MSG) {
await api.showMessageBox({ title: 'Format', message: `Format on paste skipped: ${msg}`, kind: 'warning' });
}
} finally {
inFlightRef.current = false;
}
}
const ignoreError = () => undefined;
interface EditorProps {
tab: Tab;
@@ -239,12 +195,23 @@ export function Editor({ tab }: Readonly<EditorProps>) {
],
});
const view = new EditorView({ state: startState, parent: containerRef.current });
const container = containerRef.current;
if (!container) return;
const view = new EditorView({ state: startState, parent: container });
viewRef.current = view;
editorViewRegistry.register(tab.id, view);
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
};
container.addEventListener('contextmenu', onContextMenu);
return () => {
if (contentSyncTimerRef.current) clearTimeout(contentSyncTimerRef.current);
container.removeEventListener('contextmenu', onContextMenu);
editorViewRegistry.unregister(tab.id);
view.destroy();
viewRef.current = null;
@@ -307,15 +274,7 @@ export function Editor({ tab }: Readonly<EditorProps>) {
<>
<div
ref={containerRef}
role="textbox"
aria-multiline="true"
aria-label="Code editor"
className="h-full w-full min-h-0"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
}}
/>
{contextMenu && (
<EditorContextMenu
+55
View File
@@ -0,0 +1,55 @@
import type { EditorView } from '@codemirror/view';
import { api } from '../../api';
import type { Tab } from '../../types';
import { formatDocumentContent, NO_FORMATTER_PLAIN_MSG } from '../../lib/formatDocument';
import { useEditorStore } from '../../state/editorStore';
import { useUiStore } from '../../state/uiStore';
function findTextTab(id: string) {
return useEditorStore.getState().tabs.find((x) => x.id === id && x.viewKind === 'text');
}
export async function languageForFormat(tabId: string, tab: Tab, snapshot: string): Promise<string> {
let lang = tab.languageOverride ?? tab.language;
if (tab.languageOverride || (tab.path && tab.language !== 'plaintext')) return lang;
try {
const detected = await api.detectLanguage(tab.path ?? '', snapshot);
if (detected && detected !== tab.language) {
useEditorStore.getState().updateTab(tabId, { language: detected });
lang = detected;
}
} catch {
return lang;
}
return lang;
}
export async function applyFormatOnPaste(
tabId: string,
view: EditorView,
snapshot: string,
inFlightRef: { current: boolean },
): Promise<void> {
try {
const tab = findTextTab(tabId);
if (!tab) return;
if (!useUiStore.getState().formatOnPaste) return;
const lang = await languageForFormat(tabId, tab, snapshot);
const formatted = await formatDocumentContent(snapshot, lang);
if (formatted === snapshot) return;
if (view.state.doc.toString() !== snapshot) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: formatted } });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg !== NO_FORMATTER_PLAIN_MSG) {
await api.showMessageBox({ title: 'Format', message: `Format on paste skipped: ${msg}`, kind: 'warning' });
}
} finally {
inFlightRef.current = false;
}
}
+27 -151
View File
@@ -4,29 +4,17 @@ import { DirEntry } from '../../types';
import { useSessionStore } from '../../state/sessionStore';
import { useUiStore } from '../../state/uiStore';
import { useEditorStore } from '../../state/editorStore';
import { isImageFilePath, useFileSystem } from '../../hooks/useFileSystem';
import { useFileSystem } from '../../hooks/useFileSystem';
import { treeIconForEntry } from '../../lib/fileIcons';
// ---------------------------------------------------------------------------
// Page size
// ---------------------------------------------------------------------------
const PAGE_SIZE = 100;
// ---------------------------------------------------------------------------
// State types
// ---------------------------------------------------------------------------
interface DirState {
entries: DirEntry[];
/** How many entries from `entries` are currently shown */
shown: number;
loading: boolean;
error: string | null;
}
interface FlatEntry {
entry: DirEntry;
depth: number;
}
import {
buildFlatList,
defaultPreviewKindForPath,
fileTreeReducer,
initialFileTreeState,
parentPathFor,
type DirState,
type FlatEntry,
} from './fileTreeModel';
interface ContextMenu {
x: number;
@@ -34,121 +22,6 @@ interface ContextMenu {
entry: DirEntry;
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
type Action =
| { type: 'LOAD_START'; path: string }
| { type: 'LOAD_OK'; path: string; entries: DirEntry[] }
| { type: 'LOAD_ERR'; path: string; error: string }
| { type: 'TOGGLE_EXPAND'; path: string }
| { type: 'SHOW_MORE'; path: string }
| { type: 'SET_SELECTED'; path: string | null };
interface State {
dirStates: Map<string, DirState>;
expandedPaths: Set<string>;
selectedPath: string | null;
}
function cloneMap<K, V>(m: Map<K, V>): Map<K, V> {
return new Map(m);
}
function cloneSet<T>(s: Set<T>): Set<T> {
return new Set(s);
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'LOAD_START': {
const next = cloneMap(state.dirStates);
next.set(action.path, {
entries: next.get(action.path)?.entries ?? [],
shown: next.get(action.path)?.shown ?? PAGE_SIZE,
loading: true,
error: null,
});
return { ...state, dirStates: next };
}
case 'LOAD_OK': {
const next = cloneMap(state.dirStates);
next.set(action.path, {
entries: action.entries,
shown: PAGE_SIZE,
loading: false,
error: null,
});
return { ...state, dirStates: next };
}
case 'LOAD_ERR': {
const next = cloneMap(state.dirStates);
const prev = next.get(action.path);
next.set(action.path, {
entries: prev?.entries ?? [],
shown: prev?.shown ?? PAGE_SIZE,
loading: false,
error: action.error,
});
return { ...state, dirStates: next };
}
case 'TOGGLE_EXPAND': {
const exp = cloneSet(state.expandedPaths);
if (exp.has(action.path)) {
exp.delete(action.path);
} else {
exp.add(action.path);
}
return { ...state, expandedPaths: exp };
}
case 'SHOW_MORE': {
const next = cloneMap(state.dirStates);
const prev = next.get(action.path);
if (prev) {
next.set(action.path, { ...prev, shown: prev.shown + PAGE_SIZE });
}
return { ...state, dirStates: next };
}
case 'SET_SELECTED':
return { ...state, selectedPath: action.path };
default:
return state;
}
}
const initialState: State = {
dirStates: new Map(),
expandedPaths: new Set(),
selectedPath: null,
};
// ---------------------------------------------------------------------------
// Build flat visible list for keyboard navigation
// ---------------------------------------------------------------------------
function buildFlatList(
dirPath: string,
dirStates: Map<string, DirState>,
expandedPaths: Set<string>,
depth: number,
out: FlatEntry[],
): void {
const ds = dirStates.get(dirPath);
if (!ds) return;
const visible = ds.entries.slice(0, ds.shown);
for (const entry of visible) {
out.push({ entry, depth });
if (entry.isDir && expandedPaths.has(entry.path)) {
buildFlatList(entry.path, dirStates, expandedPaths, depth + 1, out);
}
}
}
function defaultPreviewKindForPath(path: string, language: string): 'text' | 'markdownPreviewOnly' | 'image' {
if (isImageFilePath(path)) return 'image';
if (language === 'markdown') return 'markdownPreviewOnly';
return 'text';
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
@@ -161,7 +34,7 @@ export function FileTree() {
const updateTab = useEditorStore((s) => s.updateTab);
const { openFile: openFileWithSystem } = useFileSystem();
const [state, dispatch] = useReducer(reducer, initialState);
const [state, dispatch] = useReducer(fileTreeReducer, initialFileTreeState);
const { dirStates, expandedPaths, selectedPath } = state;
const containerRef = useRef<HTMLDivElement>(null);
@@ -301,7 +174,7 @@ export function FileTree() {
dispatch({ type: 'TOGGLE_EXPAND', path: cur.entry.path });
return;
}
const parentPath = cur.entry.path.substring(0, cur.entry.path.lastIndexOf('/'));
const parentPath = parentPathFor(cur.entry.path);
const parentEntry = flat.find((f) => f.entry.path === parentPath);
if (parentEntry) dispatch({ type: 'SET_SELECTED', path: parentEntry.entry.path });
}
@@ -336,10 +209,6 @@ export function FileTree() {
if (!sidebarOpen || !workspaceRoot) return null;
const rootState = dirStates.get(workspaceRoot);
const ctxExisting = contextMenu
? (tabs.find((t) => t.path === contextMenu.entry.path) ?? null)
: null;
const shouldOfferPreview = ctxExisting?.viewKind === 'hex';
return (
<div
@@ -376,6 +245,10 @@ export function FileTree() {
{/* Context menu */}
{contextMenu && (
(() => {
const existing = tabs.find((t) => t.path === contextMenu.entry.path) ?? null;
const shouldOfferPreview = existing?.viewKind === 'hex';
return (
<ContextMenuOverlay
x={contextMenu.x}
y={contextMenu.y}
@@ -395,6 +268,8 @@ export function FileTree() {
}}
onClose={() => setContextMenu(null)}
/>
);
})()
)}
</div>
);
@@ -507,11 +382,11 @@ function EntryRow({
const icon = treeIconForEntry(entry.name, entry.isDir);
return (
<>
<button
type="button"
role="treeitem"
aria-selected={isSelected}
tabIndex={-1}
<button
type="button"
role="treeitem"
aria-selected={isSelected}
tabIndex={-1}
data-entry-name={entry.name}
style={{
paddingLeft: depth * 16 + 8 + 'px',
@@ -582,9 +457,10 @@ function ContextMenuOverlay({ x, y, entry, viewToggleLabel, onOpen, onOpenHex, o
aria-label="File context menu"
tabIndex={0}
className="fixed z-50 rounded shadow-lg py-1 min-w-[160px] border"
style={{ left: x, top: y, backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-color)' }}
onClick={(e) => e.stopPropagation()}
>
style={{ left: x, top: y, backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-color)' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-4 py-1 text-sm hover:bg-white/8 transition-colors"
style={{ color: 'var(--text-primary)' }}
+128
View File
@@ -0,0 +1,128 @@
import type { DirEntry } from '../../types';
export const FILE_TREE_PAGE_SIZE = 100;
export interface DirState {
entries: DirEntry[];
shown: number;
loading: boolean;
error: string | null;
}
export interface FlatEntry {
entry: DirEntry;
depth: number;
}
export type FileTreeAction =
| { type: 'LOAD_START'; path: string }
| { type: 'LOAD_OK'; path: string; entries: DirEntry[] }
| { type: 'LOAD_ERR'; path: string; error: string }
| { type: 'TOGGLE_EXPAND'; path: string }
| { type: 'SHOW_MORE'; path: string }
| { type: 'SET_SELECTED'; path: string | null };
export interface FileTreeState {
dirStates: Map<string, DirState>;
expandedPaths: Set<string>;
selectedPath: string | null;
}
export const initialFileTreeState: FileTreeState = {
dirStates: new Map(),
expandedPaths: new Set(),
selectedPath: null,
};
function cloneMap<K, V>(m: Map<K, V>): Map<K, V> {
return new Map(m);
}
function cloneSet<T>(s: Set<T>): Set<T> {
return new Set(s);
}
export function fileTreeReducer(state: FileTreeState, action: FileTreeAction): FileTreeState {
switch (action.type) {
case 'LOAD_START': {
const next = cloneMap(state.dirStates);
next.set(action.path, {
entries: next.get(action.path)?.entries ?? [],
shown: next.get(action.path)?.shown ?? FILE_TREE_PAGE_SIZE,
loading: true,
error: null,
});
return { ...state, dirStates: next };
}
case 'LOAD_OK': {
const next = cloneMap(state.dirStates);
next.set(action.path, {
entries: action.entries,
shown: FILE_TREE_PAGE_SIZE,
loading: false,
error: null,
});
return { ...state, dirStates: next };
}
case 'LOAD_ERR': {
const next = cloneMap(state.dirStates);
const prev = next.get(action.path);
next.set(action.path, {
entries: prev?.entries ?? [],
shown: prev?.shown ?? FILE_TREE_PAGE_SIZE,
loading: false,
error: action.error,
});
return { ...state, dirStates: next };
}
case 'TOGGLE_EXPAND': {
const exp = cloneSet(state.expandedPaths);
if (exp.has(action.path)) {
exp.delete(action.path);
} else {
exp.add(action.path);
}
return { ...state, expandedPaths: exp };
}
case 'SHOW_MORE': {
const next = cloneMap(state.dirStates);
const prev = next.get(action.path);
if (prev) {
next.set(action.path, { ...prev, shown: prev.shown + FILE_TREE_PAGE_SIZE });
}
return { ...state, dirStates: next };
}
case 'SET_SELECTED':
return { ...state, selectedPath: action.path };
default:
return state;
}
}
export function buildFlatList(
dirPath: string,
dirStates: Map<string, DirState>,
expandedPaths: Set<string>,
depth: number,
out: FlatEntry[],
): void {
const ds = dirStates.get(dirPath);
if (!ds) return;
const visible = ds.entries.slice(0, ds.shown);
for (const entry of visible) {
out.push({ entry, depth });
if (entry.isDir && expandedPaths.has(entry.path)) {
buildFlatList(entry.path, dirStates, expandedPaths, depth + 1, out);
}
}
}
export function defaultPreviewKindForPath(path: string, language: string): 'text' | 'markdownPreviewOnly' | 'image' {
if (/\.(png|jpe?g|gif|webp|svg|ico|bmp)$/i.test(path)) return 'image';
if (language === 'markdown') return 'markdownPreviewOnly';
return 'text';
}
export function parentPathFor(path: string): string {
return path.substring(0, path.lastIndexOf('/'));
}
+15 -26
View File
@@ -3,27 +3,7 @@ import { findNext, findPrevious, replaceNext, replaceAll, setSearchQuery, Search
import { useUiStore } from '../../state/uiStore';
import { useEditorStore } from '../../state/editorStore';
import { editorViewRegistry } from '../../state/editorViewRegistry';
function countMatches(doc: string, query: string, caseSensitive: boolean, regexp: boolean): number {
if (!query) return 0;
try {
const flags = caseSensitive ? 'g' : 'gi';
const pattern = regexp ? query : query.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const re = new RegExp(pattern, flags);
let count = 0;
for (const _ of doc.matchAll(re)) count++;
return count;
} catch {
return 0;
}
}
function matchCountLabel(query: string, regexError: string | null, matchCount: number, currentMatch: number): string {
if (!query || regexError) return '';
if (matchCount === 0) return 'No results';
if (currentMatch === 0) return `${matchCount} found`;
return `${currentMatch} of ${matchCount}`;
}
import { countMatches, matchCountLabel } from './findReplaceModel';
export function FindReplace() {
const findPanelOpen = useUiStore((s) => s.findPanelOpen);
@@ -40,6 +20,7 @@ export function FindReplace() {
const [regexError, setRegexError] = useState<string | null>(null);
const queryInputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDialogElement>(null);
// Validate regex and compute match count whenever search params change
useEffect(() => {
@@ -109,6 +90,15 @@ export function FindReplace() {
return () => globalThis.removeEventListener('keydown', handleKeyDown);
}, [findPanelOpen, toggleFindPanel]);
useEffect(() => {
if (!findPanelOpen) return;
const panel = panelRef.current;
if (!panel) return;
const stopPanelKeyDown = (e: KeyboardEvent) => e.stopPropagation();
panel.addEventListener('keydown', stopPanelKeyDown);
return () => panel.removeEventListener('keydown', stopPanelKeyDown);
}, [findPanelOpen]);
const handleFindNext = useCallback(() => {
if (!activeTabId) return;
const view = editorViewRegistry.get(activeTabId);
@@ -151,12 +141,11 @@ export function FindReplace() {
if (!findPanelOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
<dialog
ref={panelRef}
open
aria-label="Find and replace"
className="absolute top-2 right-2 z-50 w-[440px] bg-gray-800 border border-gray-600 rounded shadow-lg text-sm text-gray-200"
onKeyDown={(e) => e.stopPropagation()}
>
{/* Find row */}
<div className="flex items-center gap-1 px-2 py-1.5">
@@ -297,6 +286,6 @@ export function FindReplace() {
<div className="w-6 shrink-0" />
</div>
)}
</div>
</dialog>
);
}
@@ -0,0 +1,25 @@
export function countMatches(doc: string, query: string, caseSensitive: boolean, regexp: boolean): number {
if (!query) return 0;
try {
const flags = caseSensitive ? 'g' : 'gi';
const pattern = regexp ? query : query.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const re = new RegExp(pattern, flags);
let count = 0;
let m = re.exec(doc);
while (m !== null) {
count++;
if (!m[0]) re.lastIndex++;
m = re.exec(doc);
}
return count;
} catch {
return 0;
}
}
export function matchCountLabel(query: string, regexError: string | null, matchCount: number, currentMatch: number): string {
if (!query || regexError) return '';
if (matchCount === 0) return 'No results';
if (currentMatch === 0) return `${matchCount} found`;
return `${currentMatch} of ${matchCount}`;
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function ImageView({ tab }: Readonly<ImageViewProps>) {
data-testid="image-view"
className="flex h-full items-center justify-center text-gray-500 text-sm"
>
{tab.path ? 'Loading image preview...' : 'No image path available'}
{loadError ?? (tab.path ? 'Loading image preview...' : 'No image path available')}
</div>
);
}
@@ -70,7 +70,7 @@ function SplitView({ tab }: Readonly<SplitViewProps>) {
const previewPaneRef = useRef<HTMLDivElement>(null);
const syncingFromRef = useRef<'editor' | 'preview' | null>(null);
const onMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const onMouseDown = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDragging(true);
@@ -201,17 +201,11 @@ function SplitView({ tab }: Readonly<SplitViewProps>) {
</div>
{/* Resize handle */}
<div
role="separator"
<button
type="button"
aria-label="Resize editor and preview"
aria-orientation="vertical"
tabIndex={0}
className="w-1 shrink-0 bg-gray-700 hover:bg-blue-500 cursor-col-resize transition-colors"
className="w-1 shrink-0 bg-gray-700 hover:bg-blue-500 cursor-col-resize transition-colors border-0 p-0"
onMouseDown={onMouseDown}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); setSplitPct((p) => Math.max(15, p - 5)); }
else if (e.key === 'ArrowRight') { e.preventDefault(); setSplitPct((p) => Math.min(85, p + 5)); }
}}
/>
{/* Preview pane */}
+9 -11
View File
@@ -36,18 +36,17 @@ export function Preferences() {
return (
<>
<button
type="button"
className="fixed inset-0 bg-black/50 z-40 border-0 p-0 cursor-default"
aria-label="Close preferences"
onClick={() => setPreferencesOpen(false)}
/>
<div className="fixed inset-0 z-40 flex items-center justify-center pointer-events-none">
<div
type="button"
className="fixed inset-0 bg-black/50 z-40 border-0 p-0"
aria-label="Close preferences"
onClick={() => setPreferencesOpen(false)}
/>
<dialog
open
data-testid="preferences-modal"
role="dialog"
aria-modal="true"
aria-label="ByteDraft Preferences"
className="bg-theme-surface border border-theme rounded-lg shadow-xl w-[600px] max-h-[80vh] overflow-y-auto p-6 relative text-theme pointer-events-auto"
className="fixed z-40 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-theme-surface border border-theme rounded-lg shadow-xl w-[600px] max-h-[80vh] overflow-y-auto p-6 text-theme"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
@@ -132,8 +131,7 @@ export function Preferences() {
))}
</div>
</section>
</div>
</div>
</dialog>
</>
);
}
+6 -4
View File
@@ -1,8 +1,10 @@
import { useEditorStore } from '../../state/editorStore';
import { api } from '../../api';
import type { Tab, ViewMode } from '../../types';
import type { Tab } from '../../types';
import { TabStrip } from './TabStrip';
type MarkdownMode = 'source' | 'split' | 'preview';
// ── Platform detection ────────────────────────────────────────────────────────
const isMac = navigator.userAgent.includes('Mac') && !navigator.userAgent.includes('Windows');
@@ -47,16 +49,16 @@ function MarkdownViewSelector() {
if (!activeTab) return null;
const tab = activeTab;
const VIEW_KIND_TO_MODE: Record<string, ViewMode> = {
const VIEW_KIND_TO_MODE: Record<string, MarkdownMode> = {
markdown: 'split',
markdownPreviewOnly: 'preview',
};
const currentMode: ViewMode = VIEW_KIND_TO_MODE[activeTab.viewKind ?? ''] ?? 'source';
const currentMode: MarkdownMode = VIEW_KIND_TO_MODE[activeTab.viewKind ?? ''] ?? 'source';
function modeBtn(
label: string,
icon: React.ReactNode,
mode: ViewMode,
mode: MarkdownMode,
viewKind: Tab['viewKind'],
) {
const active = currentMode === mode;
+2 -2
View File
@@ -74,8 +74,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
let newActive = state.activeTabId;
if (state.activeTabId === id) {
if (newTabs.length === 0) newActive = null;
else if (idx < newTabs.length) newActive = newTabs[idx]!.id;
else newActive = newTabs[newTabs.length - 1]!.id;
else if (idx < newTabs.length) newActive = newTabs[idx].id;
else newActive = newTabs[newTabs.length - 1].id;
}
return { tabs: newTabs, activeTabId: newActive };
});
-1
View File
@@ -46,7 +46,6 @@ export interface SessionData {
workspaceRoot: string | null;
themeName: string;
dockLayout?: object | null;
/** @deprecated Migrated to {@link UiPrefs} / `ui-prefs.json`; still read from old `session.json`. */
formatOnSave?: boolean;
formatOnPaste?: boolean;
wordWrap?: boolean;
+174
View File
@@ -0,0 +1,174 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CommandPalette } from '../src/components/CommandPalette';
import { editorViewRegistry } from '../src/state/editorViewRegistry';
import { useEditorStore } from '../src/state/editorStore';
import { useUiStore } from '../src/state/uiStore';
import type { Tab } from '../src/types';
function activeTab(): Tab {
return {
id: 'tab-command',
path: '/tmp/notes.md',
content: 'one\ntwo\nthree\nfour',
encoding: 'UTF-8',
language: 'markdown',
languageOverride: null,
isDirty: false,
viewKind: 'text',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
};
}
function resetStores() {
const tab = activeTab();
useUiStore.setState({
commandPaletteOpen: true,
wordWrap: false,
showLineEndings: false,
sidebarOpen: true,
findPanelOpen: false,
preferencesOpen: false,
});
useEditorStore.setState({ tabs: [tab], activeTabId: tab.id });
}
describe('CommandPalette', () => {
beforeEach(() => {
resetStores();
editorViewRegistry.unregister('tab-command');
vi.restoreAllMocks();
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = vi.fn();
}
});
it('does not render when closed', () => {
useUiStore.setState({ commandPaletteOpen: false });
render(<CommandPalette />);
expect(screen.queryByTestId('command-palette-panel')).not.toBeInTheDocument();
});
it('renders command results and closes from the backdrop', () => {
render(<CommandPalette />);
expect(screen.getByTestId('command-palette-panel')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new tab/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /close command palette/i }));
expect(useUiStore.getState().commandPaletteOpen).toBe(false);
});
it('filters commands by label and category and shows empty results', () => {
render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.change(input, { target: { value: 'wrap' } });
expect(screen.getByRole('button', { name: /show word wrap/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new tab/i })).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: 'no such command' } });
expect(screen.getByText('No commands found.')).toBeInTheDocument();
});
it('executes selected commands from clicks and Enter', () => {
const { rerender } = render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.change(input, { target: { value: 'wrap' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUiStore.getState()).toMatchObject({
wordWrap: true,
commandPaletteOpen: false,
});
useUiStore.setState({ commandPaletteOpen: true });
rerender(<CommandPalette />);
fireEvent.click(screen.getByRole('button', { name: /new tab/i }));
expect(useEditorStore.getState().tabs.length).toBe(2);
expect(useUiStore.getState().commandPaletteOpen).toBe(false);
});
it('moves selected command with arrow keys before Enter', () => {
render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowUp' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUiStore.getState().commandPaletteOpen).toBe(false);
expect(useEditorStore.getState().tabs.length).toBe(1);
});
it('toggles find and preferences commands', () => {
const { rerender } = render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.change(input, { target: { value: 'find' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUiStore.getState().findPanelOpen).toBe(true);
useUiStore.setState({ commandPaletteOpen: true });
rerender(<CommandPalette />);
fireEvent.change(screen.getByPlaceholderText(/Type a command/), { target: { value: 'preferences' } });
fireEvent.keyDown(screen.getByPlaceholderText(/Type a command/), { key: 'Enter' });
expect(useUiStore.getState().preferencesOpen).toBe(true);
});
it('closes on Escape without executing a command', () => {
render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.keyDown(input, { key: 'Escape' });
expect(useUiStore.getState().commandPaletteOpen).toBe(false);
expect(useEditorStore.getState().tabs.length).toBe(1);
});
it('shows and executes go-to-line mode against the editor view', async () => {
const dispatch = vi.fn();
const focus = vi.fn();
editorViewRegistry.register('tab-command', {
state: {
doc: {
lines: 4,
line: vi.fn((lineNo: number) => ({ from: lineNo * 100 })),
},
},
dispatch,
focus,
} as never);
render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.change(input, { target: { value: ':99' } });
expect(screen.getByText('Go to line 4 (max 4)')).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith({ selection: { anchor: 400 }, scrollIntoView: true });
expect(focus).toHaveBeenCalledOnce();
expect(useUiStore.getState().commandPaletteOpen).toBe(false);
});
});
it('ignores invalid go-to-line input and remains open', () => {
render(<CommandPalette />);
const input = screen.getByPlaceholderText(/Type a command/);
fireEvent.change(input, { target: { value: ':' } });
expect(screen.getByText('Type a line number (max 4)')).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(useUiStore.getState().commandPaletteOpen).toBe(true);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { DockRightActions } from '../src/components/Dock/DockRightActions';
import { useEditorStore } from '../src/state/editorStore';
import type { ComponentProps } from 'react';
import type { Tab } from '../src/types';
const makeTab = (overrides: Partial<Tab> = {}): Tab => ({
id: 'tab-1',
path: '/notes.md',
content: '# Notes',
encoding: 'UTF-8',
language: 'markdown',
languageOverride: null,
isDirty: false,
viewKind: 'markdown',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
});
function renderActions() {
return render(<DockRightActions {...({} as ComponentProps<typeof DockRightActions>)} />);
}
describe('DockRightActions', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
});
it('creates a new empty tab from the header action', () => {
renderActions();
fireEvent.click(screen.getByLabelText('New Tab'));
expect(useEditorStore.getState().tabs).toHaveLength(1);
});
it('does not show markdown view controls for non-markdown tabs', () => {
useEditorStore.setState({
tabs: [makeTab({ language: 'plaintext', viewKind: 'text' })],
activeTabId: 'tab-1',
});
renderActions();
expect(screen.queryByLabelText('Source')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Split')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Preview')).not.toBeInTheDocument();
});
it('switches the active markdown tab between source, split, and preview modes', () => {
useEditorStore.setState({
tabs: [makeTab()],
activeTabId: 'tab-1',
});
renderActions();
fireEvent.click(screen.getByLabelText('Source'));
expect(useEditorStore.getState().tabs[0].viewKind).toBe('text');
fireEvent.click(screen.getByLabelText('Split'));
expect(useEditorStore.getState().tabs[0].viewKind).toBe('markdown');
fireEvent.click(screen.getByLabelText('Preview'));
expect(useEditorStore.getState().tabs[0].viewKind).toBe('markdownPreviewOnly');
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorView } from '@codemirror/view';
import { applyFormatOnPaste, languageForFormat } from '../src/components/Editor/formatOnPaste';
import type { WindowApi } from '../src/api';
import type { Tab } from '../src/types';
import { useEditorStore } from '../src/state/editorStore';
import { useUiStore } from '../src/state/uiStore';
function setGlobalApi(apiMock: Partial<WindowApi>) {
(globalThis as typeof globalThis & { api?: Partial<WindowApi> }).api = apiMock;
}
const makeTab = (overrides: Partial<Tab> = {}): Tab => ({
id: 'tab-1',
path: null,
content: 'raw',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
encoding: 'UTF-8',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
});
function makeView(doc: string) {
return {
state: {
doc: {
length: doc.length,
toString: () => doc,
},
},
dispatch: vi.fn(),
} as unknown as EditorView;
}
describe('format on paste helpers', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
useUiStore.setState({ formatOnPaste: true });
});
afterEach(() => {
Reflect.deleteProperty(globalThis as typeof globalThis & { api?: Partial<WindowApi> }, 'api');
vi.restoreAllMocks();
});
it('detects plaintext tab language and updates the tab before formatting', async () => {
const detectLanguage = vi.fn().mockResolvedValue('json');
setGlobalApi({ detectLanguage });
const tab = makeTab({ id: 'detect-me' });
useEditorStore.setState({ tabs: [tab], activeTabId: 'detect-me' });
await expect(languageForFormat('detect-me', tab, '{"a":1}')).resolves.toBe('json');
expect(detectLanguage).toHaveBeenCalledWith('', '{"a":1}');
expect(useEditorStore.getState().tabs[0].language).toBe('json');
});
it('skips detection when language override is present', async () => {
const detectLanguage = vi.fn().mockResolvedValue('json');
setGlobalApi({ detectLanguage });
await expect(languageForFormat('tab-1', makeTab({ languageOverride: 'rust' }), 'fn main() {}')).resolves.toBe('rust');
expect(detectLanguage).not.toHaveBeenCalled();
});
it('skips detection for known file languages', async () => {
const detectLanguage = vi.fn().mockResolvedValue('json');
setGlobalApi({ detectLanguage });
await expect(languageForFormat('tab-1', makeTab({ path: '/tmp/main.rs', language: 'rust' }), 'fn main() {}')).resolves.toBe('rust');
expect(detectLanguage).not.toHaveBeenCalled();
});
it('keeps the current language when detection fails', async () => {
setGlobalApi({ detectLanguage: vi.fn().mockRejectedValue(new Error('unavailable')) });
await expect(languageForFormat('tab-1', makeTab(), 'plain text')).resolves.toBe('plaintext');
});
it('formats the pasted snapshot when the document is still unchanged', async () => {
setGlobalApi({
detectLanguage: vi.fn().mockResolvedValue('json'),
formatDocument: vi.fn().mockResolvedValue('{"a": 1}'),
});
useEditorStore.setState({ tabs: [makeTab({ id: 'format-me' })], activeTabId: 'format-me' });
const view = makeView('{"a":1}');
const inFlightRef = { current: true };
await applyFormatOnPaste('format-me', view, '{"a":1}', inFlightRef);
expect(view.dispatch).toHaveBeenCalledWith({ changes: { from: 0, to: 7, insert: '{"a": 1}' } });
expect(inFlightRef.current).toBe(false);
});
it('does not replace content when the editor changed after paste', async () => {
setGlobalApi({
detectLanguage: vi.fn().mockResolvedValue('json'),
formatDocument: vi.fn().mockResolvedValue('{"a": 1}'),
});
useEditorStore.setState({ tabs: [makeTab({ id: 'stale' })], activeTabId: 'stale' });
const view = makeView('{"a":1} plus typing');
await applyFormatOnPaste('stale', view, '{"a":1}', { current: true });
expect(view.dispatch).not.toHaveBeenCalled();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EditorContextMenu } from '../src/components/EditorContextMenu/EditorContextMenu';
import { editorViewRegistry } from '../src/state/editorViewRegistry';
function makeView(selectionText = 'selected') {
const replaceSelection = vi.fn((text: string) => ({ changes: text }));
return {
state: {
selection: { main: { from: 1, to: 1 + selectionText.length } },
sliceDoc: vi.fn(() => selectionText),
replaceSelection,
},
dispatch: vi.fn(),
};
}
describe('EditorContextMenu', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue('pasted text'),
},
});
});
afterEach(() => {
editorViewRegistry.unregister('tab-1');
vi.restoreAllMocks();
});
it('renders editing actions and closes from backdrop and Escape', () => {
const onClose = vi.fn();
render(<EditorContextMenu x={10} y={20} tabId="tab-1" onClose={onClose} />);
expect(screen.getByRole('menuitem', { name: /cut/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /copy/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /paste/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /select all/i })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('cuts selected text to the clipboard and deletes it from the editor', () => {
const view = makeView('cut me');
editorViewRegistry.register('tab-1', view as never);
const onClose = vi.fn();
render(<EditorContextMenu x={10} y={20} tabId="tab-1" onClose={onClose} />);
fireEvent.click(screen.getByRole('menuitem', { name: /cut/i }));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('cut me');
expect(view.state.replaceSelection).toHaveBeenCalledWith('');
expect(view.dispatch).toHaveBeenCalledWith({ changes: '' });
expect(onClose).toHaveBeenCalledOnce();
});
it('copies selected text without dispatching editor changes', () => {
const view = makeView('copy me');
editorViewRegistry.register('tab-1', view as never);
const onClose = vi.fn();
render(<EditorContextMenu x={10} y={20} tabId="tab-1" onClose={onClose} />);
fireEvent.click(screen.getByRole('menuitem', { name: /copy/i }));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('copy me');
expect(view.dispatch).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledOnce();
});
it('pastes clipboard text into the editor asynchronously', async () => {
const view = makeView();
editorViewRegistry.register('tab-1', view as never);
const onClose = vi.fn();
render(<EditorContextMenu x={10} y={20} tabId="tab-1" onClose={onClose} />);
fireEvent.click(screen.getByRole('menuitem', { name: /paste/i }));
expect(onClose).toHaveBeenCalledOnce();
await waitFor(() => {
expect(view.state.replaceSelection).toHaveBeenCalledWith('pasted text');
expect(view.dispatch).toHaveBeenCalledWith({ changes: 'pasted text' });
});
});
it('closes safely when an editor view is missing', () => {
const onClose = vi.fn();
render(<EditorContextMenu x={10} y={20} tabId="missing" onClose={onClose} />);
fireEvent.click(screen.getByRole('menuitem', { name: /cut/i }));
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledOnce();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { ErrorBoundary } from '../src/ErrorBoundary';
function BrokenChild(): ReactNode {
throw new Error('render exploded');
}
describe('ErrorBoundary', () => {
it('renders children when no error is thrown', () => {
render(
<ErrorBoundary>
<span>healthy child</span>
</ErrorBoundary>,
);
expect(screen.getByText('healthy child')).toBeInTheDocument();
});
it('renders the fallback with the thrown error message', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<BrokenChild />
</ErrorBoundary>,
);
expect(screen.getByText('ByteDraft failed to render')).toBeInTheDocument();
expect(screen.getByText(/render exploded/)).toBeInTheDocument();
consoleError.mockRestore();
});
});
+175
View File
@@ -0,0 +1,175 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import { api } from '../src/api';
import { FileTree } from '../src/components/FileTree';
import { useEditorStore } from '../src/state/editorStore';
import { useSessionStore } from '../src/state/sessionStore';
import { useUiStore } from '../src/state/uiStore';
import type { DirEntry, Tab } from '../src/types';
const dir = (path: string): DirEntry => ({
name: path.split('/').pop() ?? path,
path,
isDir: true,
});
const file = (path: string): DirEntry => ({
name: path.split('/').pop() ?? path,
path,
isDir: false,
});
function tab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'tab-1',
path: '/workspace/readme.md',
content: '# Readme',
encoding: 'UTF-8',
language: 'markdown',
languageOverride: null,
isDirty: false,
viewKind: 'hex',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
};
}
function resetStores() {
useSessionStore.setState({ workspaceRoot: '/workspace' });
useUiStore.setState({ sidebarOpen: true });
useEditorStore.setState({ tabs: [], activeTabId: null });
}
describe('FileTree component', () => {
let readDirectoryMock: MockInstance<typeof api.readDirectory>;
let openFileMock: MockInstance<typeof api.openFile>;
beforeEach(() => {
resetStores();
vi.restoreAllMocks();
readDirectoryMock = vi.spyOn(api, 'readDirectory').mockImplementation(async (path) => {
if (path === '/workspace') return [dir('/workspace/src'), file('/workspace/readme.md')];
if (path === '/workspace/src') return [file('/workspace/src/main.ts')];
return [];
});
openFileMock = vi.spyOn(api, 'openFile').mockResolvedValue({
path: '/workspace/readme.md',
content: '# Readme',
encoding: 'UTF-8',
language: 'markdown',
viewKind: 'markdownPreviewOnly',
lineEnding: 'LF',
imageMime: null,
});
vi.spyOn(api, 'detectLanguage').mockResolvedValue('plaintext');
});
it('does not render without a workspace root or when the sidebar is closed', () => {
useSessionStore.setState({ workspaceRoot: null });
const { rerender } = render(<FileTree />);
expect(screen.queryByTestId('file-tree')).not.toBeInTheDocument();
useSessionStore.setState({ workspaceRoot: '/workspace' });
useUiStore.setState({ sidebarOpen: false });
rerender(<FileTree />);
expect(screen.queryByTestId('file-tree')).not.toBeInTheDocument();
});
it('loads and renders workspace entries', async () => {
render(<FileTree />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('src')).toBeInTheDocument();
expect(screen.getByText('readme.md')).toBeInTheDocument();
expect(readDirectoryMock).toHaveBeenCalledWith('/workspace');
});
it('renders a directory load error', async () => {
vi.spyOn(api, 'readDirectory').mockRejectedValueOnce(new Error('permission denied'));
render(<FileTree />);
expect(await screen.findByText('permission denied')).toBeInTheDocument();
});
it('expands folders and renders nested files', async () => {
render(<FileTree />);
fireEvent.click(await screen.findByText('src'));
expect(await screen.findByText('main.ts')).toBeInTheDocument();
expect(readDirectoryMock).toHaveBeenCalledWith('/workspace/src');
});
it('opens new files through the file-system flow', async () => {
render(<FileTree />);
fireEvent.click(await screen.findByText('readme.md'));
await waitFor(() => {
expect(openFileMock).toHaveBeenCalledWith('/workspace/readme.md');
expect(useEditorStore.getState().tabs[0]).toMatchObject({
path: '/workspace/readme.md',
content: '# Readme',
viewKind: 'markdownPreviewOnly',
});
});
});
it('selects an existing hex tab and switches it back to preview on open', async () => {
useEditorStore.setState({ tabs: [tab()], activeTabId: null });
render(<FileTree />);
fireEvent.click(await screen.findByText('readme.md'));
await waitFor(() => {
expect(useEditorStore.getState().activeTabId).toBe('tab-1');
expect(useEditorStore.getState().tabs[0].viewKind).toBe('markdownPreviewOnly');
expect(openFileMock).not.toHaveBeenCalled();
});
});
it('opens the context menu and toggles an existing file into hex view', async () => {
useEditorStore.setState({ tabs: [tab({ viewKind: 'text' })], activeTabId: null });
render(<FileTree />);
fireEvent.contextMenu(await screen.findByText('readme.md'), { clientX: 40, clientY: 80 });
fireEvent.click(screen.getByRole('button', { name: 'View in Hex' }));
expect(useEditorStore.getState().activeTabId).toBe('tab-1');
expect(useEditorStore.getState().tabs[0].viewKind).toBe('hex');
});
it('creates a hex tab for a file that is not already open', async () => {
render(<FileTree />);
fireEvent.contextMenu(await screen.findByText('readme.md'), { clientX: 40, clientY: 80 });
fireEvent.click(screen.getByRole('button', { name: 'View in Hex' }));
await waitFor(() => {
expect(useEditorStore.getState().tabs[0]).toMatchObject({
path: '/workspace/readme.md',
viewKind: 'hex',
language: 'markdown',
content: '# Readme',
});
});
});
it('pages large directories with the Show more button', async () => {
readDirectoryMock.mockResolvedValueOnce(
Array.from({ length: 105 }, (_, index) => file(`/workspace/file-${index}.txt`)),
);
render(<FileTree />);
expect(await screen.findByText('file-0.txt')).toBeInTheDocument();
expect(screen.queryByText('file-104.txt')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show 5 more…' }));
expect(screen.getByText('file-104.txt')).toBeInTheDocument();
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import type { DirEntry } from '../src/types';
import {
buildFlatList,
defaultPreviewKindForPath,
fileTreeReducer,
initialFileTreeState,
parentPathFor,
FILE_TREE_PAGE_SIZE,
} from '../src/components/FileTree/fileTreeModel';
const file = (path: string): DirEntry => ({
name: path.split('/').pop() ?? path,
path,
isDir: false,
});
const dir = (path: string): DirEntry => ({
name: path.split('/').pop() ?? path,
path,
isDir: true,
});
describe('fileTreeReducer', () => {
it('starts loading a directory while preserving previous entries', () => {
const state = fileTreeReducer(initialFileTreeState, {
type: 'LOAD_OK',
path: '/root',
entries: [file('/root/a.txt')],
});
const next = fileTreeReducer(state, { type: 'LOAD_START', path: '/root' });
expect(next.dirStates.get('/root')).toMatchObject({
entries: [file('/root/a.txt')],
shown: FILE_TREE_PAGE_SIZE,
loading: true,
error: null,
});
});
it('stores loaded entries and resets paging', () => {
const next = fileTreeReducer(initialFileTreeState, {
type: 'LOAD_OK',
path: '/root',
entries: [dir('/root/src'), file('/root/readme.md')],
});
expect(next.dirStates.get('/root')).toMatchObject({
shown: FILE_TREE_PAGE_SIZE,
loading: false,
error: null,
});
expect(next.dirStates.get('/root')?.entries).toHaveLength(2);
});
it('stores load errors without discarding existing entries', () => {
const state = fileTreeReducer(initialFileTreeState, {
type: 'LOAD_OK',
path: '/root',
entries: [file('/root/a.txt')],
});
const next = fileTreeReducer(state, { type: 'LOAD_ERR', path: '/root', error: 'Access denied' });
expect(next.dirStates.get('/root')).toMatchObject({
entries: [file('/root/a.txt')],
loading: false,
error: 'Access denied',
});
});
it('toggles expansion, selection, and page size independently', () => {
const loaded = fileTreeReducer(initialFileTreeState, {
type: 'LOAD_OK',
path: '/root',
entries: Array.from({ length: 120 }, (_, index) => file(`/root/${index}.txt`)),
});
const expanded = fileTreeReducer(loaded, { type: 'TOGGLE_EXPAND', path: '/root' });
const selected = fileTreeReducer(expanded, { type: 'SET_SELECTED', path: '/root/1.txt' });
const paged = fileTreeReducer(selected, { type: 'SHOW_MORE', path: '/root' });
expect(paged.expandedPaths.has('/root')).toBe(true);
expect(paged.selectedPath).toBe('/root/1.txt');
expect(paged.dirStates.get('/root')?.shown).toBe(FILE_TREE_PAGE_SIZE * 2);
});
it('ignores SHOW_MORE for unknown directories', () => {
const next = fileTreeReducer(initialFileTreeState, { type: 'SHOW_MORE', path: '/missing' });
expect(next.dirStates.size).toBe(0);
});
});
describe('buildFlatList', () => {
it('returns visible entries with depth and respects collapsed directories', () => {
const dirStates = new Map([
['/root', {
entries: [dir('/root/src'), file('/root/readme.md')],
shown: 10,
loading: false,
error: null,
}],
['/root/src', {
entries: [file('/root/src/main.ts')],
shown: 10,
loading: false,
error: null,
}],
]);
const out: ReturnType<typeof makeFlatList> = [];
buildFlatList('/root', dirStates, new Set(), 0, out);
expect(out.map((item) => item.entry.path)).toEqual(['/root/src', '/root/readme.md']);
expect(out.map((item) => item.depth)).toEqual([0, 0]);
});
it('recurses into expanded directories and applies shown limit', () => {
const dirStates = new Map([
['/root', {
entries: [dir('/root/src'), file('/root/readme.md')],
shown: 1,
loading: false,
error: null,
}],
['/root/src', {
entries: [file('/root/src/main.ts')],
shown: 10,
loading: false,
error: null,
}],
]);
const out: ReturnType<typeof makeFlatList> = [];
buildFlatList('/root', dirStates, new Set(['/root/src']), 0, out);
expect(out.map((item) => item.entry.path)).toEqual(['/root/src', '/root/src/main.ts']);
expect(out.map((item) => item.depth)).toEqual([0, 1]);
});
});
describe('FileTree path helpers', () => {
it('chooses the default preview kind from file path and language', () => {
expect(defaultPreviewKindForPath('/root/image.PNG', 'plaintext')).toBe('image');
expect(defaultPreviewKindForPath('/root/readme.md', 'markdown')).toBe('markdownPreviewOnly');
expect(defaultPreviewKindForPath('/root/readme.md', 'plaintext')).toBe('text');
});
it('returns the parent path used by left-arrow navigation', () => {
expect(parentPathFor('/root/src/main.ts')).toBe('/root/src');
});
});
function makeFlatList() {
return [] as Array<{ entry: DirEntry; depth: number }>;
}
+134
View File
@@ -0,0 +1,134 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { findNext, findPrevious, replaceAll, replaceNext } from '@codemirror/search';
import { FindReplace } from '../src/components/FindReplace';
import { editorViewRegistry } from '../src/state/editorViewRegistry';
import { useEditorStore } from '../src/state/editorStore';
import { useUiStore } from '../src/state/uiStore';
vi.mock('@codemirror/search', async () => {
const actual = await vi.importActual<typeof import('@codemirror/search')>('@codemirror/search');
return {
...actual,
findNext: vi.fn(() => true),
findPrevious: vi.fn(() => true),
replaceNext: vi.fn(),
replaceAll: vi.fn(),
};
});
const mockFindNext = vi.mocked(findNext);
const mockFindPrevious = vi.mocked(findPrevious);
const mockReplaceNext = vi.mocked(replaceNext);
const mockReplaceAll = vi.mocked(replaceAll);
function makeView(docText: string, selection = { from: 0, to: 0 }) {
return {
dispatch: vi.fn(),
state: {
doc: {
toString: () => docText,
},
selection: {
main: {
...selection,
empty: selection.from === selection.to,
},
},
sliceDoc: (from: number, to: number) => docText.slice(from, to),
},
};
}
describe('FindReplace component', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
useEditorStore.setState({ tabs: [], activeTabId: 'tab-1' });
useUiStore.setState({ findPanelOpen: true });
mockFindNext.mockClear();
mockFindPrevious.mockClear();
mockReplaceNext.mockClear();
mockReplaceAll.mockClear();
});
afterEach(() => {
editorViewRegistry.unregister('tab-1');
vi.unstubAllGlobals();
});
it('dispatches a CodeMirror search query and reports match counts', async () => {
const view = makeView('alpha beta alpha');
editorViewRegistry.register('tab-1', view as never);
render(<FindReplace />);
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } });
await waitFor(() => expect(view.dispatch).toHaveBeenCalled());
expect(screen.getByText('2 found')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Next match (Enter)'));
expect(mockFindNext).toHaveBeenCalledWith(view);
expect(screen.getByText('1 of 2')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Previous match (Shift+Enter)'));
expect(mockFindPrevious).toHaveBeenCalledWith(view);
expect(screen.getByText('2 of 2')).toBeInTheDocument();
});
it('prefills the query from the active editor selection', async () => {
const view = makeView('selected text', { from: 0, to: 8 });
editorViewRegistry.register('tab-1', view as never);
render(<FindReplace />);
await waitFor(() => expect(screen.getByDisplayValue('selected')).toBeInTheDocument());
});
it('shows invalid regex errors without dispatching a bad query', async () => {
const view = makeView('alpha');
editorViewRegistry.register('tab-1', view as never);
render(<FindReplace />);
fireEvent.click(screen.getByTitle('Use regular expression'));
view.dispatch.mockClear();
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: '[' } });
expect(await screen.findByText(/unterminated character class|invalid regular expression/i)).toBeInTheDocument();
expect(view.dispatch).not.toHaveBeenCalled();
});
it('runs replace actions from the expanded replace row', async () => {
const view = makeView('alpha alpha');
editorViewRegistry.register('tab-1', view as never);
render(<FindReplace />);
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } });
await screen.findByText('2 found');
fireEvent.click(screen.getByTitle('Show replace'));
fireEvent.change(screen.getByPlaceholderText('Replace'), { target: { value: 'beta' } });
fireEvent.click(screen.getByText('Replace'));
fireEvent.click(screen.getByText('All'));
expect(mockReplaceNext).toHaveBeenCalledWith(view);
expect(mockReplaceAll).toHaveBeenCalledWith(view);
expect(screen.getByText('No results')).toBeInTheDocument();
});
it('closes when Escape is pressed', () => {
const view = makeView('alpha');
editorViewRegistry.register('tab-1', view as never);
render(<FindReplace />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(useUiStore.getState().findPanelOpen).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { countMatches, matchCountLabel } from '../src/components/FindReplace/findReplaceModel';
describe('countMatches', () => {
it('returns zero for empty queries', () => {
expect(countMatches('abc', '', false, false)).toBe(0);
});
it('counts literal matches case-insensitively by default', () => {
expect(countMatches('Foo foo FOO', 'foo', false, false)).toBe(3);
});
it('honors case-sensitive literal matching', () => {
expect(countMatches('Foo foo FOO', 'foo', true, false)).toBe(1);
});
it('escapes regex metacharacters when regexp is off', () => {
expect(countMatches('a.c abc a-c a.c', 'a.c', false, false)).toBe(2);
});
it('counts regex matches when regexp is on', () => {
expect(countMatches('item-1 item-22 item-x', String.raw`item-\d+`, false, true)).toBe(2);
});
it('handles zero-width regex matches without looping forever', () => {
expect(countMatches('abc', String.raw`\b`, false, true)).toBe(2);
});
it('treats invalid regex as no matches', () => {
expect(countMatches('abc', '[', false, true)).toBe(0);
});
});
describe('matchCountLabel', () => {
it('hides the label without a query or while regex has an error', () => {
expect(matchCountLabel('', null, 2, 1)).toBe('');
expect(matchCountLabel('abc', 'Invalid', 2, 1)).toBe('');
});
it('summarizes no results, unfocused results, and current match position', () => {
expect(matchCountLabel('abc', null, 0, 0)).toBe('No results');
expect(matchCountLabel('abc', null, 3, 0)).toBe('3 found');
expect(matchCountLabel('abc', null, 3, 2)).toBe('2 of 3');
});
});
+87
View File
@@ -0,0 +1,87 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '../src/api';
import { ImageView } from '../src/components/ImageView';
import type { Tab } from '../src/types';
function imageTab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'image-test',
path: '/tmp/photo.png',
content: '',
encoding: 'UTF-8',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'image',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
};
}
describe('ImageView', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('shows an empty-path message and does not read binary data', () => {
const readBinary = vi.spyOn(api, 'readBinary').mockResolvedValue([]);
render(<ImageView tab={imageTab({ path: null })} />);
expect(screen.getByText('No image path available')).toBeInTheDocument();
expect(readBinary).not.toHaveBeenCalled();
});
it('loads image bytes into an object URL and revokes it on unmount', async () => {
vi.spyOn(api, 'readBinary').mockResolvedValue([1, 2, 3]);
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
const { unmount } = render(<ImageView tab={imageTab({ path: '/tmp/photo.webp' })} />);
expect(screen.getByText('Loading image preview...')).toBeInTheDocument();
const img = await screen.findByRole('img', { name: '/tmp/photo.webp' });
expect(img).toHaveAttribute('src', 'blob:test-image');
expect(createObjectURL.mock.calls[0][0]).toBeInstanceOf(Blob);
unmount();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-image');
});
it('uses explicit imageMime over path inference', async () => {
vi.spyOn(api, 'readBinary').mockResolvedValue(new Uint8Array([1, 2, 3]) as unknown as number[]);
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-svg');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
render(<ImageView tab={imageTab({ path: '/tmp/photo.bin', imageMime: 'image/svg+xml' })} />);
await screen.findByRole('img');
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(blob.type).toBe('image/svg+xml');
});
it('shows a load failure when readBinary rejects', async () => {
vi.spyOn(api, 'readBinary').mockRejectedValue(new Error('disk read failed'));
render(<ImageView tab={imageTab()} />);
await waitFor(() => {
expect(screen.getByText('Loading image preview...')).toBeInTheDocument();
});
expect(await screen.findByText(/Failed to load image: disk read failed/)).toBeInTheDocument();
});
it('shows a preview failure when the image element errors', async () => {
vi.spyOn(api, 'readBinary').mockResolvedValue([1, 2, 3]);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
render(<ImageView tab={imageTab()} />);
fireEvent.error(await screen.findByRole('img'));
expect(screen.getByText('Failed to load image preview')).toBeInTheDocument();
});
});
+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MarkdownPreview } from '../src/components/MarkdownPreview';
import { editorViewRegistry } from '../src/state/editorViewRegistry';
import type { Tab } from '../src/types';
vi.mock('../src/components/Editor', () => ({
Editor: ({ tab }: { tab: Tab }) => <div data-testid="mock-editor">Editor {tab.id}</div>,
}));
function markdownTab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'markdown-test',
path: '/tmp/readme.md',
content: '# Heading\n\n- one\n- two',
encoding: 'UTF-8',
language: 'markdown',
languageOverride: null,
isDirty: false,
viewKind: 'markdown',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
};
}
function setScrollMetrics(element: HTMLElement, scrollHeight: number, clientHeight: number) {
Object.defineProperty(element, 'scrollHeight', { configurable: true, value: scrollHeight });
Object.defineProperty(element, 'clientHeight', { configurable: true, value: clientHeight });
}
function makeEditorView(scrollDOM: HTMLElement) {
return {
scrollDOM,
state: {
doc: {
lines: 4,
line: vi.fn((lineNo: number) => ({ from: lineNo * 10 })),
},
},
dispatch: vi.fn(),
};
}
describe('MarkdownPreview', () => {
afterEach(() => {
editorViewRegistry.unregister('markdown-test');
vi.restoreAllMocks();
});
it('renders markdown preview-only mode without the source editor', () => {
render(<MarkdownPreview tab={markdownTab({ viewKind: 'markdownPreviewOnly' })} />);
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument();
expect(screen.getByText('one')).toBeInTheDocument();
expect(screen.queryByTestId('mock-editor')).not.toBeInTheDocument();
});
it('renders split mode with source and preview panes', () => {
render(<MarkdownPreview tab={markdownTab()} />);
expect(screen.getByTestId('markdown-source-pane')).toHaveStyle({ width: '50%' });
expect(screen.getByTestId('mock-editor')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument();
});
it('clamps resize dragging between narrow and wide split limits', () => {
render(<MarkdownPreview tab={markdownTab()} />);
const handle = screen.getByRole('button', { name: /resize editor and preview/i });
const sourcePane = screen.getByTestId('markdown-source-pane');
Object.defineProperty(handle.parentElement, 'getBoundingClientRect', {
configurable: true,
value: () => ({ left: 0, width: 1000 }),
});
fireEvent.mouseDown(handle);
fireEvent.mouseMove(window, { clientX: 20 });
expect(sourcePane).toHaveStyle({ width: '15%' });
fireEvent.mouseMove(window, { clientX: 950 });
expect(sourcePane).toHaveStyle({ width: '85%' });
fireEvent.mouseUp(window);
expect(handle.parentElement).toHaveStyle({ cursor: 'default' });
});
it('syncs editor scroll progress into the preview pane', async () => {
const editorScroller = document.createElement('div');
setScrollMetrics(editorScroller, 1000, 500);
editorScroller.scrollTop = 250;
const addListener = vi.spyOn(editorScroller, 'addEventListener');
const view = makeEditorView(editorScroller);
editorViewRegistry.register('markdown-test', view as never);
render(<MarkdownPreview tab={markdownTab()} />);
const previewScroller = screen.getByTestId('markdown-preview-scroll');
setScrollMetrics(previewScroller, 800, 400);
await waitFor(() => {
expect(addListener).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true });
});
fireEvent.scroll(editorScroller);
await waitFor(() => {
expect(previewScroller.scrollTop).toBe(200);
});
});
it('syncs preview scroll progress back to the editor selection', async () => {
const editorScroller = document.createElement('div');
setScrollMetrics(editorScroller, 1000, 500);
const addListener = vi.spyOn(editorScroller, 'addEventListener');
const view = makeEditorView(editorScroller);
editorViewRegistry.register('markdown-test', view as never);
render(<MarkdownPreview tab={markdownTab()} />);
const previewScroller = screen.getByTestId('markdown-preview-scroll');
setScrollMetrics(previewScroller, 800, 400);
previewScroller.scrollTop = 400;
await waitFor(() => {
expect(addListener).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true });
});
fireEvent.scroll(previewScroller);
await waitFor(() => {
expect(view.state.doc.line).toHaveBeenCalledWith(4);
expect(view.dispatch).toHaveBeenCalledWith({
selection: { anchor: 40 },
scrollIntoView: true,
});
expect(editorScroller.scrollTop).toBe(500);
});
});
it('aligns preview position from cursor line when source content changes', async () => {
const { rerender } = render(<MarkdownPreview tab={markdownTab({ cursorLine: 3, content: 'a\nb\nc\nd' })} />);
const previewScroller = screen.getByTestId('markdown-preview-scroll');
setScrollMetrics(previewScroller, 1000, 500);
rerender(<MarkdownPreview tab={markdownTab({ cursorLine: 4, content: 'a\nb\nc\nd' })} />);
await waitFor(() => {
expect(previewScroller.scrollTop).toBe(500);
});
});
});
+124 -3
View File
@@ -1,14 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { StatusBar } from '../src/components/StatusBar/StatusBar';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { StatusBar } from '../src/components/StatusBar';
import { useEditorStore } from '../src/state/editorStore';
import type { WindowApi } from '../src/api';
import type { Tab } from '../src/types';
function resetStores() {
useEditorStore.setState({ tabs: [], activeTabId: null });
}
function setGlobalApi(apiMock: Partial<WindowApi>) {
(globalThis as typeof globalThis & { api?: Partial<WindowApi> }).api = apiMock;
}
const makeTab = (overrides: Partial<Tab> = {}): Tab => ({
id: 't1',
path: '/f.txt',
content: '',
language: 'plaintext',
languageOverride: null,
isDirty: false,
viewKind: 'text',
encoding: 'UTF-8',
lineEnding: 'LF',
cursorLine: 1,
cursorCol: 1,
...overrides,
});
describe('StatusBar', () => {
beforeEach(resetStores);
afterEach(() => {
Reflect.deleteProperty(globalThis as typeof globalThis & { api?: Partial<WindowApi> }, 'api');
});
it('renders the status bar', () => {
render(<StatusBar />);
@@ -99,4 +123,101 @@ describe('StatusBar', () => {
'SQL',
]);
});
it('loads language and encoding menus from the desktop API when available', async () => {
setGlobalApi({
listLanguages: vi.fn().mockResolvedValue([{ id: 'zig', label: 'Zig' }]),
listEncodings: vi.fn().mockResolvedValue(['UTF-8', 'Shift_JIS']),
});
useEditorStore.setState({
tabs: [makeTab({ id: 'custom-tab', language: 'zig' })],
activeTabId: 'custom-tab',
});
render(<StatusBar />);
expect(await screen.findByText('Zig')).toBeInTheDocument();
fireEvent.click(screen.getByText('UTF-8'));
expect(await screen.findByText('Shift_JIS')).toBeInTheDocument();
});
it('updates the active tab language from the language menu and closes on outside click', () => {
useEditorStore.setState({
tabs: [makeTab({ id: 'lang-change' })],
activeTabId: 'lang-change',
});
render(<StatusBar />);
fireEvent.click(screen.getByText('Plain Text'));
fireEvent.click(screen.getByText('Rust'));
expect(useEditorStore.getState().tabs[0].languageOverride).toBe('rust');
expect(screen.queryByTestId('language-menu')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Rust'));
expect(screen.getByTestId('language-menu')).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByTestId('language-menu')).not.toBeInTheDocument();
});
it('reloads file content when a new encoding is selected', async () => {
setGlobalApi({
listLanguages: vi.fn().mockResolvedValue([]),
listEncodings: vi.fn().mockResolvedValue(['UTF-8', 'Windows-1252']),
readFileWithEncoding: vi.fn().mockResolvedValue({
path: '/encoded.txt',
content: 'decoded',
encoding: 'Windows-1252',
language: 'plaintext',
lineEnding: 'LF',
}),
});
useEditorStore.setState({
tabs: [makeTab({ id: 'encoded-tab', path: '/encoded.txt' })],
activeTabId: 'encoded-tab',
});
render(<StatusBar />);
fireEvent.click(screen.getByText('UTF-8'));
fireEvent.click(await screen.findByText('Windows-1252'));
await waitFor(() => {
expect(useEditorStore.getState().tabs[0]).toEqual(
expect.objectContaining({ content: 'decoded', encoding: 'Windows-1252' }),
);
});
});
it('shows encoding reload errors without changing tab content', async () => {
setGlobalApi({
listLanguages: vi.fn().mockResolvedValue([]),
listEncodings: vi.fn().mockResolvedValue(['UTF-8', 'UTF-16 LE']),
readFileWithEncoding: vi.fn().mockRejectedValue(new Error('Cannot decode file')),
});
useEditorStore.setState({
tabs: [makeTab({ id: 'error-tab', path: '/bad.txt', content: 'original' })],
activeTabId: 'error-tab',
});
render(<StatusBar />);
fireEvent.click(screen.getByText('UTF-8'));
fireEvent.click(await screen.findByText('UTF-16 LE'));
expect(await screen.findByTestId('encoding-error')).toHaveTextContent('Cannot decode file');
expect(useEditorStore.getState().tabs[0].content).toBe('original');
});
it('hides encoding controls for non-text preview tabs', () => {
useEditorStore.setState({
tabs: [makeTab({ id: 'image-tab', viewKind: 'image', path: '/logo.png' })],
activeTabId: 'image-tab',
});
render(<StatusBar />);
expect(screen.queryByTestId('encoding-selector')).not.toBeInTheDocument();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { TweaksPanel } from '../src/components/TweaksPanel';
import { useUiStore } from '../src/state/uiStore';
function resetUiStore() {
useUiStore.setState({
tweaksPanelOpen: false,
layoutMode: 'classic',
accentColor: 'blue',
findPanelOpen: false,
commandPaletteOpen: false,
preferencesOpen: false,
});
}
describe('TweaksPanel', () => {
beforeEach(resetUiStore);
it('does not render when closed', () => {
render(<TweaksPanel />);
expect(screen.queryByTestId('tweaks-panel')).not.toBeInTheDocument();
});
it('updates layout and accent preferences', () => {
useUiStore.setState({ tweaksPanelOpen: true });
render(<TweaksPanel />);
fireEvent.click(screen.getByRole('button', { name: 'Minimal' }));
fireEvent.click(screen.getByRole('button', { name: 'Green' }));
expect(useUiStore.getState().layoutMode).toBe('minimal');
expect(useUiStore.getState().accentColor).toBe('green');
});
it('toggles editor panels from action buttons', () => {
useUiStore.setState({ tweaksPanelOpen: true });
render(<TweaksPanel />);
fireEvent.click(screen.getByRole('button', { name: 'Find' }));
fireEvent.click(screen.getByRole('button', { name: 'Command Palette' }));
fireEvent.click(screen.getByRole('button', { name: 'Preferences' }));
expect(useUiStore.getState()).toMatchObject({
findPanelOpen: true,
commandPaletteOpen: true,
preferencesOpen: true,
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { RustBridgeCmd, type RustBridgeCmdName } from '../src/bridge/commands';
describe('RustBridgeCmd', () => {
it('contains the command names used by the Rust JSON bridge', () => {
expect(RustBridgeCmd).toMatchObject({
OpenFile: 'open_file',
RevertFile: 'revert_file',
SaveFile: 'save_file',
ReadFileWithEncoding: 'read_file_with_encoding',
ReadBinary: 'read_binary',
LoadSession: 'load_session',
SaveSession: 'save_session',
LoadUiPrefs: 'load_ui_prefs',
SaveUiPrefs: 'save_ui_prefs',
UpdateTabContent: 'update_tab_content',
ReadDirectory: 'read_directory',
ListWorkspaceFiles: 'list_workspace_files',
DetectLanguage: 'detect_language',
LintDocument: 'lint_document',
FormatDocument: 'format_document',
DetectEncoding: 'detect_encoding',
ListLanguages: 'list_languages',
ListEncodings: 'list_encodings',
} satisfies Record<string, RustBridgeCmdName>);
});
it('does not contain duplicate wire command values', () => {
const values = Object.values(RustBridgeCmd);
expect(new Set(values).size).toBe(values.length);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { __setDesktopBridgeForTests, desktopInvoke, getDesktopBridge, type ByteDraftDesktop } from '../src/desktop-bridge';
const makeBridge = (): ByteDraftDesktop => ({
windowLabel: 'main',
isMainWindow: true,
invoke: vi.fn().mockResolvedValue('ok'),
minimize: vi.fn(),
toggleMaximize: vi.fn(),
closeWindow: vi.fn(),
emit: vi.fn(),
subscribe: vi.fn(() => () => {}),
});
describe('desktop bridge', () => {
afterEach(() => {
__setDesktopBridgeForTests(undefined);
Reflect.deleteProperty(globalThis, 'byteDraft');
});
it('prefers a test bridge when one is installed', () => {
const bridge = makeBridge();
__setDesktopBridgeForTests(bridge);
expect(getDesktopBridge()).toBe(bridge);
});
it('falls back to global byteDraft when no test bridge is installed', () => {
const bridge = makeBridge();
globalThis.byteDraft = bridge;
expect(getDesktopBridge()).toBe(bridge);
});
it('returns null when no desktop bridge is available', () => {
expect(getDesktopBridge()).toBeNull();
});
it('invokes desktop commands through the active bridge', async () => {
const bridge = makeBridge();
__setDesktopBridgeForTests(bridge);
await expect(desktopInvoke('ping', { value: 1 })).resolves.toBe('ok');
expect(bridge.invoke).toHaveBeenCalledWith('ping', { value: 1 });
});
it('rejects desktop commands when the bridge is unavailable', async () => {
await expect(desktopInvoke('ping')).rejects.toThrow('Desktop bridge unavailable');
});
});
+46
View File
@@ -0,0 +1,46 @@
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { afterEach, describe, expect, it } from 'vitest';
import { makeLineEndingsPlugin } from '../src/extensions/lineEndings';
let view: EditorView | null = null;
function createView(doc: string, lineEnding: 'LF' | 'CRLF' | 'CR' = 'LF') {
const parent = document.createElement('div');
document.body.append(parent);
view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [makeLineEndingsPlugin(lineEnding)],
}),
});
return { parent, view };
}
describe('makeLineEndingsPlugin', () => {
afterEach(() => {
view?.destroy();
view = null;
document.body.replaceChildren();
});
it('renders a line-ending marker after each non-final line', () => {
const { parent } = createView('one\ntwo\nthree', 'CRLF');
const markers = [...parent.querySelectorAll('.cm-widgetBuffer + span')];
expect(markers).toHaveLength(2);
expect(markers.map((marker) => marker.textContent)).toEqual(['CRLF', 'CRLF']);
});
it('updates markers when the document changes', () => {
const { parent, view: editor } = createView('one', 'LF');
expect(parent.textContent).not.toContain('LF');
editor.dispatch({ changes: { from: 3, insert: '\ntwo' } });
expect(parent.textContent).toContain('LF');
});
});
+14
View File
@@ -22,3 +22,17 @@ if (typeof ResizeObserver === 'undefined') {
disconnect() {}
};
}
if (!URL.createObjectURL) {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: () => 'blob:test',
});
}
if (!URL.revokeObjectURL) {
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: () => {},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { render, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useKeyBindings, type KeyBinding } from '../src/hooks/useKeyBindings';
function Harness({ bindings }: { bindings: KeyBinding[] }) {
useKeyBindings(bindings);
return null;
}
describe('useKeyBindings', () => {
it('runs the matching callback and prevents the browser default', () => {
const callback = vi.fn();
render(<Harness bindings={[{ key: 's', ctrlKey: true, callback }]} />);
const event = new KeyboardEvent('keydown', { key: 'S', ctrlKey: true, bubbles: true, cancelable: true });
const prevented = !document.dispatchEvent(event);
expect(callback).toHaveBeenCalledTimes(1);
expect(prevented).toBe(true);
});
it('requires all modifier keys to match', () => {
const callback = vi.fn();
render(<Harness bindings={[{ key: 'k', ctrlKey: true, shiftKey: true, callback }]} />);
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(callback).not.toHaveBeenCalled();
});
it('removes the keydown listener on unmount', () => {
const callback = vi.fn();
const { unmount } = render(<Harness bindings={[{ key: 'p', metaKey: true, callback }]} />);
unmount();
fireEvent.keyDown(document, { key: 'p', metaKey: true });
expect(callback).not.toHaveBeenCalled();
});
});
+147
View File
@@ -5,6 +5,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
import type { ByteDraftDesktop } from '../src/desktop-bridge';
import { useEditorStore } from '../src/state/editorStore';
import { useSessionStore } from '../src/state/sessionStore';
import { useUiStore } from '../src/state/uiStore';
import type { Tab } from '../src/types';
const makeTab = (id: string, overrides: Partial<Tab> = {}): Tab => ({
@@ -278,3 +280,148 @@ describe('windowBus — cross-window file tracking', () => {
expect(onSync).toHaveBeenCalledWith('win-new', ['/foo.txt'], expect.any(Boolean));
});
});
describe('windowBus — serialization and settings sync', () => {
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
useSessionStore.setState({ workspaceRoot: '/workspace', themeName: 'dark' });
useUiStore.setState({ accentColor: 'green' });
mockInvoke.mockReset();
mockEmit.mockReset();
mockSubscribe.mockReset();
installDefaultBridge();
});
afterEach(() => {
__setDesktopBridgeForTests(undefined);
});
it('serializes active window context with a tab transfer payload', async () => {
const tab = makeTab('serialized');
useEditorStore.setState({ tabs: [tab], activeTabId: 'serialized' });
const { serializeTabForTransfer } = await import('../src/state/windowBus');
const payload = serializeTabForTransfer('serialized');
expect(JSON.parse(payload!)).toEqual(
expect.objectContaining({
id: 'serialized',
workspaceRoot: '/workspace',
themeName: 'dark',
accentColor: 'green',
}),
);
});
it('returns null when serializing a missing tab', async () => {
const { serializeTabForTransfer } = await import('../src/state/windowBus');
expect(serializeTabForTransfer('missing')).toBeNull();
});
it('ingests serialized window context into editor, session, and UI stores', async () => {
const tab = makeTab('incoming-context', {
workspaceRoot: '/other',
themeName: 'light',
accentColor: 'amber',
} as Partial<Tab>);
const { ingestSerializedTab } = await import('../src/state/windowBus');
ingestSerializedTab(tab as never);
expect(useEditorStore.getState().activeTabId).toBe('incoming-context');
expect(useSessionStore.getState().workspaceRoot).toBe('/other');
expect(useSessionStore.getState().themeName).toBe('light');
expect(useUiStore.getState().accentColor).toBe('amber');
});
it('broadcasts open files and settings through the bridge', async () => {
mockEmit.mockResolvedValue(undefined);
const { broadcastOpenFiles, broadcastSettings } = await import('../src/state/windowBus');
await broadcastOpenFiles(['/workspace/a.txt']);
await broadcastSettings({ themeName: 'light' });
expect(mockEmit).toHaveBeenCalledWith('files:sync', { label: 'main', paths: ['/workspace/a.txt'] });
expect(mockEmit).toHaveBeenCalledWith('settings:sync', { themeName: 'light' });
});
it('swallows broadcast failures so browser windows keep working', async () => {
mockEmit.mockRejectedValue(new Error('bridge unavailable'));
const { broadcastOpenFiles, broadcastSettings } = await import('../src/state/windowBus');
await expect(broadcastOpenFiles(['/workspace/a.txt'])).resolves.toBeUndefined();
await expect(broadcastSettings({ themeName: 'light' })).resolves.toBeUndefined();
});
it('listens for settings sync payloads and falls back to a noop unlisten on subscribe failure', async () => {
let settingsHandler: ((payload: unknown) => void) | undefined;
mockSubscribe.mockImplementation((channel: string, cb: (payload: unknown) => void) => {
if (channel === 'settings:sync') settingsHandler = cb;
return () => {};
});
const { listenForSettingsSync } = await import('../src/state/windowBus');
const onSync = vi.fn();
const unlisten = await listenForSettingsSync(onSync);
settingsHandler!({ accentColor: 'blue' });
expect(onSync).toHaveBeenCalledWith({ accentColor: 'blue' });
expect(typeof unlisten).toBe('function');
mockSubscribe.mockImplementation(() => {
throw new Error('subscribe failed');
});
await expect(listenForSettingsSync(vi.fn())).resolves.toEqual(expect.any(Function));
});
});
describe('windowBus — prewarm child windows', () => {
const originalRandomUUID = crypto.randomUUID;
beforeEach(() => {
useEditorStore.setState({ tabs: [], activeTabId: null });
mockInvoke.mockReset();
Object.defineProperty(crypto, 'randomUUID', {
configurable: true,
value: () => '12345678-1234-1234-1234-123456789abc',
});
installDefaultBridge();
});
afterEach(() => {
Object.defineProperty(crypto, 'randomUUID', {
configurable: true,
value: originalRandomUUID,
});
__setDesktopBridgeForTests(undefined);
});
it('uses a prewarmed child window for the next tear-off and starts another prewarm', async () => {
mockInvoke.mockResolvedValue(undefined);
useEditorStore.setState({ tabs: [makeTab('prewarm')], activeTabId: 'prewarm' });
const { prewarmChildWindow, tearOffTab } = await import('../src/state/windowBus');
await prewarmChildWindow();
await tearOffTab('prewarm', 10, 20);
expect(mockInvoke).toHaveBeenCalledWith('prewarm_child_window', { label: 'win-prewarm-12345678' });
expect(mockInvoke).toHaveBeenCalledWith(
'set_window_payload',
expect.objectContaining({ label: 'win-prewarm-12345678' }),
);
expect(mockInvoke).toHaveBeenCalledWith(
'activate_prewarm_window',
expect.objectContaining({ label: 'win-prewarm-12345678', x: 70, y: 80 }),
);
});
it('ignores prewarm failures', async () => {
mockInvoke.mockRejectedValue(new Error('cannot prewarm'));
const { prewarmChildWindow } = await import('../src/state/windowBus');
await expect(prewarmChildWindow()).resolves.toBeUndefined();
});
});
+2 -1
View File
@@ -11,7 +11,8 @@ export default defineConfig({
provider: 'v8',
reporter: ['lcov', 'text'],
reportsDirectory: '../coverage',
exclude: ['tests/**', 'e2e/**', '**/*.config.*', '**/.eslintrc.*'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/main.tsx', 'tests/**', 'e2e/**', '**/*.config.*', '**/.eslintrc.cjs'],
},
reporters: ['default', ['junit', { outputFile: '../test-results/junit.xml' }]],
},