From fe2654e62b91f0f754c5022a43fd7a0e976dcb56 Mon Sep 17 00:00:00 2001 From: Stone Mason <70180737+AnlangA@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:18:57 +0800 Subject: [PATCH] =?UTF-8?q?update(app):=20=E4=BC=98=E5=8C=96=E4=B8=B2?= =?UTF-8?q?=E5=8F=A3=E8=B0=83=E8=AF=95=E4=BD=93=E9=AA=8C=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=201.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分工作区、发送区与 MCUMgr 组件,优化深浅主题和稳定布局 - 完善串口打开、取消、关闭与失败重试,修复重复请求和连接竞态 - 修复协议响应匹配、工作区保存和导出、AI 会话隔离与取消问题 - 补充针对性回归测试,同步版本信息和发布日志 --- ARCHITECTURE.md | 15 + CHANGELOG.md | 28 + Cargo.lock | 2 +- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/ai/request_manager.rs | 5 +- src-tauri/tauri.conf.json | 2 +- src/AiWindow.vue | 12 +- src/App.vue | 12 +- .../__tests__/naive-theme.test.ts | 19 +- src/design-system/naive-theme.ts | 31 +- src/design-system/tokens/colors.css | 25 +- .../ai-activity/ai-window-authority.ts | 19 +- .../ai/__tests__/ai-activity-protocol.test.ts | 36 +- .../use-ai-session-bridge-lifecycle.test.ts | 25 + .../__tests__/use-ai-window-session.test.ts | 93 +++ .../ai/__tests__/use-ai-window-state.test.ts | 46 +- .../ai/application/use-ai-session-bridge.ts | 204 ++--- .../ai/application/use-ai-window-session.ts | 83 +- .../ai/application/use-ai-window-state.ts | 18 +- .../navigation-status-components.test.ts | 78 +- .../__tests__/use-sidebar-resize.test.ts | 47 ++ .../application/use-sidebar-resize.ts | 70 ++ src/features/app-shell/ui/AppShell.vue | 500 +----------- src/features/app-shell/ui/AppSidebar.vue | 320 ++++++++ src/features/app-shell/ui/StatusBar.vue | 11 +- .../app-shell/ui/WorkspaceWelcome.vue | 159 ++++ .../__tests__/use-send-composer.test.ts | 141 ++++ .../application/use-send-composer.ts | 121 +++ src/features/send-panel/ui/SendOptions.vue | 176 +++++ src/features/send-panel/ui/SendPanel.vue | 409 +++++----- .../serial/__tests__/async-send-loop.test.ts | 46 ++ .../serial-connection-controller.test.ts | 81 +- .../serial/application/async-send-loop.ts | 16 +- .../serial/application/connect-lifecycle.ts | 8 +- .../serial/application/reconnect-policy.ts | 8 + .../sessions/__tests__/mcumgr-session.test.ts | 50 ++ .../session-application-service.test.ts | 53 ++ .../session-protocol-runtime.test.ts | 70 ++ .../session-runtime-controller.test.ts | 212 ++++++ .../session-toolbar-controls.test.ts | 143 ++++ .../sessions/application/mcumgr-bridge.ts | 17 +- .../runtime/session-protocol-runtime.ts | 19 +- .../runtime/session-runtime-controller.ts | 167 +++- .../sessions/session-application-service.ts | 3 +- .../sessions/ui/SessionConnectionControls.vue | 233 ++++++ .../sessions/ui/SessionDisplayControls.vue | 130 ++++ src/features/sessions/ui/SessionTabs.vue | 87 ++- src/features/sessions/ui/SessionToolbar.vue | 662 ++++------------ src/features/sessions/ui/SessionView.vue | 5 + .../sessions/ui/SessionViewSwitcher.vue | 129 ++++ src/features/sessions/ui/session-view-mode.ts | 1 + .../__tests__/mcumgr-hover-tip.test.ts | 92 +++ .../__tests__/mcumgr-image-tab.test.ts | 52 +- .../terminal/__tests__/mcumgr-panel.test.ts | 141 ++++ .../terminal/__tests__/packet-filter.test.ts | 30 + .../use-packet-virtual-scroll-watch.test.ts | 30 +- .../terminal/application/use-packet-filter.ts | 4 +- .../application/use-packet-virtual-scroll.ts | 7 +- src/features/terminal/ui/DataPacketList.vue | 16 +- src/features/terminal/ui/PacketRow.vue | 2 +- .../terminal/ui/mcumgr/McumgrActionCard.vue | 79 ++ .../ui/mcumgr/McumgrActivityStrip.vue | 98 +++ .../terminal/ui/mcumgr/McumgrConfigTab.vue | 123 +-- .../ui/mcumgr/McumgrFileSystemTab.vue | 100 +-- .../terminal/ui/mcumgr/McumgrHoverTip.vue | 62 +- .../terminal/ui/mcumgr/McumgrImageTab.vue | 520 ++++++++----- .../terminal/ui/mcumgr/McumgrPanel.vue | 715 ++++++------------ .../terminal/ui/mcumgr/McumgrProgressBar.vue | 57 +- .../terminal/ui/mcumgr/McumgrResultPanel.vue | 214 ++++++ .../terminal/ui/mcumgr/McumgrSystemTab.vue | 328 ++++++++ .../terminal/ui/mcumgr/McumgrTabs.vue | 90 +++ .../workspace/__tests__/use-export.test.ts | 79 ++ .../workspace-application-service.test.ts | 29 + .../workspace-transition-coordinator.test.ts | 30 + .../application/save-queues/index.ts | 38 +- .../workspace/application/use-export.ts | 12 +- .../workspace-transition-coordinator.ts | 7 +- src/features/workspace/ui/WorkspacePanel.vue | 82 +- .../modbus-transaction-runner.test.ts | 98 ++- src/lib/__tests__/modbus-transport.test.ts | 24 + src/lib/locales/en.ts | 77 ++ src/lib/locales/zh.ts | 75 ++ src/lib/modbus/modbus-transaction-runner.ts | 53 +- src/lib/modbus/modbus-transport.ts | 96 ++- 85 files changed, 5924 insertions(+), 2387 deletions(-) create mode 100644 src/features/app-shell/__tests__/use-sidebar-resize.test.ts create mode 100644 src/features/app-shell/application/use-sidebar-resize.ts create mode 100644 src/features/app-shell/ui/AppSidebar.vue create mode 100644 src/features/app-shell/ui/WorkspaceWelcome.vue create mode 100644 src/features/send-panel/__tests__/use-send-composer.test.ts create mode 100644 src/features/send-panel/application/use-send-composer.ts create mode 100644 src/features/send-panel/ui/SendOptions.vue create mode 100644 src/features/sessions/__tests__/session-application-service.test.ts create mode 100644 src/features/sessions/__tests__/session-toolbar-controls.test.ts create mode 100644 src/features/sessions/ui/SessionConnectionControls.vue create mode 100644 src/features/sessions/ui/SessionDisplayControls.vue create mode 100644 src/features/sessions/ui/SessionViewSwitcher.vue create mode 100644 src/features/sessions/ui/session-view-mode.ts create mode 100644 src/features/terminal/__tests__/mcumgr-hover-tip.test.ts create mode 100644 src/features/terminal/__tests__/mcumgr-panel.test.ts create mode 100644 src/features/terminal/ui/mcumgr/McumgrActionCard.vue create mode 100644 src/features/terminal/ui/mcumgr/McumgrActivityStrip.vue create mode 100644 src/features/terminal/ui/mcumgr/McumgrResultPanel.vue create mode 100644 src/features/terminal/ui/mcumgr/McumgrSystemTab.vue create mode 100644 src/features/terminal/ui/mcumgr/McumgrTabs.vue diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd43d0f..205a1e5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,6 +82,21 @@ Hard rules: ## Runtime Ownership +### Presentation boundaries + +- `AppShell` coordinates the workspace; `AppSidebar` and `WorkspaceWelcome` + own navigation and empty-state presentation. `useSidebarResize` owns drag + cleanup without depending on a store. +- `SessionToolbar` adapts runtime/settings values to connection, view-switching, + and display-control components. These children only accept props and emit intent. +- `SendPanel` binds session settings to `SendOptions` and `useSendComposer`. + The composer snapshots payload/mode before asynchronous work, allows only one + pending preparation/send, and preserves drafts edited while a send is pending. +- CSS tokens and `getThemeOverrides` provide matching dark/light colors to native + controls and Naive UI, including concrete colors for Naive's alpha calculations. + +### Services + - **Workspace application service:** sole durability owner for sessions, frames, layout metadata, and feature projections. SQLite workspaces use schema v5; older on-disk schemas are rejected (no in-place migration). diff --git a/CHANGELOG.md b/CHANGELOG.md index ef97ad7..3cfaf11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to bbcom are documented here. The format is based on ## [Unreleased] +## [1.2.0] - 2026-09-08 + +### Changed + +- Decoupled workspace navigation, connection controls, send composition, and + MCUmgr panels; refreshed dark/light themes and compact-window layouts. +- Reworked MCUmgr system and image tools with compact actions, full-width hash + entry, and expandable results. Execution status now updates in a fixed row + without shifting tabs or content; action tooltips dismiss after activation. +- Serial controls now expose opening, closing, cancellation, reconnect stop, + and failed-close retry states while keeping toolbar positions stable. + +### Fixed + +- Serial open/close operations coalesce repeated requests, preserve pending + cleanup, and reject stale open or reconnect completions after cancellation. +- Send composition snapshots payload and mode, preserves newly edited drafts, + prevents duplicate submissions, and includes appended checksum bytes. +- Modbus response parsing recovers past serial noise and verifies response + identity, quantity, and write echoes before completing a transaction. +- Workspace saves account for UTF-8 and JSON escaping, transitions restore + partially stopped runtimes, and export cancellation releases file grants. +- AI event listeners detach after late registration, native cancellation cannot + lose a wakeup, and workspace changes clear stale chat bindings. +- MCUmgr rejects malformed HEX input and ignores progress from cancelled tasks. +- Paused capture clearing, full-buffer SMP updates, retained terminal search, + automatic scrolling, oversized row copying, and traffic rates remain correct. + ## [1.1.0] - 2026-08-28 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 6284812..0a28013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bbcom" -version = "1.1.0" +version = "1.2.0" dependencies = [ "base64 0.22.1", "bbcom-contracts", diff --git a/package.json b/package.json index 82e9d54..9fa78dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bbcom", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "packageManager": "pnpm@11.11.0", "engines": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3dffbb3..1939c84 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bbcom" -version = "1.1.0" +version = "1.2.0" description = "Serial Port Assistant" edition = "2024" rust-version = "1.97" diff --git a/src-tauri/src/commands/ai/request_manager.rs b/src-tauri/src/commands/ai/request_manager.rs index 72a7184..e6dc641 100644 --- a/src-tauri/src/commands/ai/request_manager.rs +++ b/src-tauri/src/commands/ai/request_manager.rs @@ -40,10 +40,13 @@ impl AiCancellation { pub async fn cancelled(&self) { loop { + // Register before checking the flag: notify_waiters does not retain + // a permit for futures created after the cancellation broadcast. + let notified = self.notify.notified(); if self.cancelled.load(Ordering::Acquire) { return; } - self.notify.notified().await; + notified.await; } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e091975..cd36bfc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.0", "productName": "bbcom", - "version": "1.1.0", + "version": "1.2.0", "identifier": "com.bbcom.app", "build": { "beforeDevCommand": "pnpm run tauri:before-dev", diff --git a/src/AiWindow.vue b/src/AiWindow.vue index d40227e..0ae81f8 100644 --- a/src/AiWindow.vue +++ b/src/AiWindow.vue @@ -1,5 +1,8 @@ + + diff --git a/src/features/app-shell/ui/StatusBar.vue b/src/features/app-shell/ui/StatusBar.vue index 6eb18bc..dfa5da7 100644 --- a/src/features/app-shell/ui/StatusBar.vue +++ b/src/features/app-shell/ui/StatusBar.vue @@ -86,7 +86,9 @@ function currentTrafficSample(): { txBytes: number; rxBytes: number; frames: num return { txBytes: rawData?.txBytes.value ?? props.session?.txBytes ?? 0, rxBytes: rawData?.rxBytes.value ?? props.session?.rxBytes ?? 0, - frames: rawData?.frames.value.length ?? props.session?.frames.length ?? 0, + frames: + (rawData?.txFrames.value ?? props.session?.txFrames ?? 0) + + (rawData?.rxFrames.value ?? props.session?.rxFrames ?? 0), }; } @@ -105,6 +107,7 @@ const frameRate = ref(0); watch( connected, (connected) => { + now.value = Date.now(); if (timer) { clearInterval(timer); timer = null; @@ -131,7 +134,8 @@ watch( txRate.value = Math.round(txDelta / elapsed); rxRate.value = Math.round(rxDelta / elapsed); } - // Frames-per-second: sample the live frame count delta. Guard the + // Sample cumulative traffic so buffer eviction and capture pause do + // not change the reported throughput. Guard the // divisor the same way as the byte rates — a zero-ms interval tick // would otherwise render "Infinity/s". if (elapsed > 0) { @@ -148,6 +152,7 @@ watch( } else { txRate.value = 0; rxRate.value = 0; + frameRate.value = 0; } }, { immediate: true }, @@ -182,7 +187,7 @@ const dataRate = computed(() => { const duration = computed(() => { if (!props.session?.startTime) return '--:--:--'; - return formatDuration(now.value - props.session.startTime); + return formatDuration(Math.max(0, now.value - props.session.startTime)); }); /** Buffer level: how full the rolling frame buffer is. */ diff --git a/src/features/app-shell/ui/WorkspaceWelcome.vue b/src/features/app-shell/ui/WorkspaceWelcome.vue new file mode 100644 index 0000000..c388f72 --- /dev/null +++ b/src/features/app-shell/ui/WorkspaceWelcome.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/features/send-panel/__tests__/use-send-composer.test.ts b/src/features/send-panel/__tests__/use-send-composer.test.ts new file mode 100644 index 0000000..20881eb --- /dev/null +++ b/src/features/send-panel/__tests__/use-send-composer.test.ts @@ -0,0 +1,141 @@ +import { effectScope, reactive, type EffectScope } from 'vue'; +import { afterEach, expect, test, vi } from 'vitest'; +import { MAX_INPUT_SIZE } from '@/types'; +import { useSendComposer, type SendComposerState } from '../application/use-send-composer'; + +const scopes: EffectScope[] = []; +afterEach(() => scopes.splice(0).forEach((scope) => scope.stop())); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function fixture(overrides: Partial = {}) { + const state = reactive({ + input: 'AA BB', + isHex: true, + lineEnding: 'none', + checksum: 'none', + disabled: false, + looping: false, + sessionId: 'session-1', + ...overrides, + }); + const deps = { + // Match the component's reactive snapshot getter, including unrelated fields. + getState: () => ({ ...state }), + calculateChecksum: vi + .fn<(data: Uint8Array, algorithm: string) => Promise<{ result: string }>>() + .mockResolvedValue({ result: '01 02' }), + send: vi.fn<(data: string, isHex: boolean) => Promise>().mockResolvedValue(true), + startLoop: vi.fn<(data: string, isHex: boolean) => boolean>().mockReturnValue(true), + stopLoop: vi.fn(), + updateInput: vi.fn(), + onSent: vi.fn(), + onError: vi.fn(), + onWarning: vi.fn(), + }; + const scope = effectScope(); + scopes.push(scope); + const composer = scope.run(() => useSendComposer(deps))!; + return { state, deps, composer, scope }; +} + +test('captures HEX mode with the payload and keeps a newly edited draft while preventing duplicate sends', async () => { + const { state, deps, composer } = fixture({ checksum: 'CRC16' }); + const checksum = deferred<{ result: string }>(); + deps.calculateChecksum.mockReturnValue(checksum.promise); + + const sending = composer.send(); + expect(composer.pending.value).toBe('send'); + await composer.send(); + await composer.toggleLoop(); + state.isHex = false; + state.input = 'next command'; + checksum.resolve({ result: '01 02' }); + await sending; + + expect(deps.calculateChecksum).toHaveBeenCalledTimes(1); + expect(deps.send).toHaveBeenCalledExactlyOnceWith('AA BB 01 02', true); + expect(deps.startLoop).not.toHaveBeenCalled(); + expect(deps.updateInput).not.toHaveBeenCalled(); + expect(composer.pending.value).toBeNull(); +}); + +test('preserves text edited during a slow transport write', async () => { + const { state, deps, composer } = fixture({ input: 'first', isHex: false }); + const write = deferred(); + deps.send.mockReturnValue(write.promise); + const sending = composer.send(); + await Promise.resolve(); + expect(deps.send).toHaveBeenCalledExactlyOnceWith('first', false); + state.input = 'second'; + write.resolve(true); + await sending; + expect(deps.updateInput).not.toHaveBeenCalled(); + expect(deps.onSent).toHaveBeenCalledOnce(); +}); + +test('adds the captured line ending and clears an unchanged draft after success', async () => { + const { deps, composer } = fixture({ input: 'AT', isHex: false, lineEnding: 'CRLF' }); + await composer.send(); + expect(deps.send).toHaveBeenCalledExactlyOnceWith('AT\r\n', false); + expect(deps.updateInput).toHaveBeenCalledExactlyOnceWith(''); +}); + +test('reports rejected transport writes and releases pending state for retry', async () => { + const { deps, composer } = fixture(); + deps.send.mockRejectedValueOnce(new Error('port closed')); + await composer.send(); + expect(deps.onError).toHaveBeenCalledExactlyOnceWith('send.error.failed'); + expect(deps.updateInput).not.toHaveBeenCalled(); + expect(composer.pending.value).toBeNull(); + await composer.send(); + expect(deps.send).toHaveBeenCalledTimes(2); + expect(deps.updateInput).toHaveBeenCalledExactlyOnceWith(''); +}); + +test('does not start a prepared loop after the connection has disconnected and reconnected', async () => { + const { state, deps, composer } = fixture({ checksum: 'CRC16' }); + const checksum = deferred<{ result: string }>(); + deps.calculateChecksum.mockReturnValue(checksum.promise); + const starting = composer.toggleLoop(); + state.disabled = true; + state.disabled = false; + checksum.resolve({ result: '01 02' }); + await starting; + expect(deps.startLoop).not.toHaveBeenCalled(); + expect(composer.pending.value).toBeNull(); +}); + +test('does not send a prepared payload after the composer scope is disposed', async () => { + const { deps, composer, scope } = fixture({ checksum: 'CRC16' }); + const checksum = deferred<{ result: string }>(); + deps.calculateChecksum.mockReturnValue(checksum.promise); + const sending = composer.send(); + scope.stop(); + checksum.resolve({ result: '01 02' }); + await sending; + expect(deps.send).not.toHaveBeenCalled(); + expect(deps.updateInput).not.toHaveBeenCalled(); +}); + +test('includes the selected checksum in byte count and validates payloads before sending', async () => { + const { state, deps, composer } = fixture({ checksum: 'CRC32' }); + expect(composer.byteCount.value).toBe(6); + state.input = 'AA B'; + expect(composer.validHex.value).toBe(false); + await composer.send(); + expect(deps.send).not.toHaveBeenCalled(); + state.isHex = false; + state.input = 'a'.repeat(MAX_INPUT_SIZE + 1); + await composer.send(); + expect(deps.onError).toHaveBeenCalledExactlyOnceWith('send.error.tooLarge'); + expect(deps.send).not.toHaveBeenCalled(); +}); diff --git a/src/features/send-panel/application/use-send-composer.ts b/src/features/send-panel/application/use-send-composer.ts new file mode 100644 index 0000000..98a2d64 --- /dev/null +++ b/src/features/send-panel/application/use-send-composer.ts @@ -0,0 +1,121 @@ +import { computed, onScopeDispose, ref, watch } from 'vue'; +import { appendLineEnding, computeSendByteCount, isValidHex, parseHex } from '@/lib/format'; +import { MAX_INPUT_SIZE } from '@/types'; +import type { ChecksumType, LineEnding } from '@/types'; + +export interface SendComposerState { + input: string; + isHex: boolean; + lineEnding: LineEnding; + checksum: 'none' | ChecksumType; + disabled?: boolean; + looping: boolean; + sessionId?: string; +} + +interface SendComposerDependencies { + getState: () => SendComposerState; + calculateChecksum: (data: Uint8Array, algorithm: ChecksumType) => Promise<{ result: string }>; + send: (data: string, isHex: boolean) => Promise; + startLoop: (data: string, isHex: boolean) => boolean; + stopLoop: () => void; + updateInput: (input: string) => void; + onSent: () => void; + onError: (key: 'send.error.tooLarge' | 'send.error.failed') => void; + onWarning: (key: 'send.error.checksumFailed') => void; +} + +/** Owns payload preparation and asynchronous sending, independent of UI and stores. */ +export function useSendComposer(deps: SendComposerDependencies) { + const pending = ref<'send' | 'loop' | null>(null); + let disposed = false; + let connectionRevision = 0; + const validHex = computed(() => { + const state = deps.getState(); + return !state.isHex || !state.input.trim() || isValidHex(state.input); + }); + const byteCount = computed(() => { + const state = deps.getState(); + return computeSendByteCount(state.input, state.isHex, state.checksum, state.lineEnding); + }); + const canSend = computed(() => { + const state = deps.getState(); + return !disposed && !state.disabled && !pending.value && !!state.input.trim() && validHex.value; + }); + + watch( + [() => deps.getState().disabled, () => deps.getState().sessionId], + ([disabled]) => { + connectionRevision += 1; + if (disabled && deps.getState().looping) deps.stopLoop(); + }, + { flush: 'sync' }, + ); + + onScopeDispose(() => { + disposed = true; + connectionRevision += 1; + }); + + async function buildPayload(state: SendComposerState): Promise { + if (state.input.length > MAX_INPUT_SIZE) { + deps.onError('send.error.tooLarge'); + return null; + } + if (!state.isHex) return appendLineEnding(state.input, state.lineEnding); + if (state.checksum === 'none') return state.input; + try { + const checksum = await deps.calculateChecksum(parseHex(state.input), state.checksum); + return `${state.input} ${checksum.result}`; + } catch { + deps.onWarning('send.error.checksumFailed'); + return state.input; + } + } + + async function run(action: 'send' | 'loop'): Promise { + if (disposed || !canSend.value || (action === 'loop' && deps.getState().looping)) return; + // Capture mode and data together; a setting or draft can change while IPC awaits. + const snapshot = { ...deps.getState() }; + const revision = connectionRevision; + pending.value = action; + const isCurrent = () => + !disposed && revision === connectionRevision && !deps.getState().disabled; + try { + const data = await buildPayload(snapshot); + if (data === null || !isCurrent()) return; + if (action === 'loop') { + if (!deps.getState().looping && !deps.startLoop(data, snapshot.isHex)) { + deps.onError('send.error.failed'); + } + return; + } + const sent = await deps.send(data, snapshot.isHex); + if (!isCurrent()) return; + if (!sent) { + deps.onError('send.error.failed'); + return; + } + const current = deps.getState(); + if ( + !current.looping && + current.input === snapshot.input && + current.isHex === snapshot.isHex + ) { + deps.updateInput(''); + } + deps.onSent(); + } catch { + if (isCurrent()) deps.onError('send.error.failed'); + } finally { + pending.value = null; + } + } + + function toggleLoop(): Promise | void { + if (deps.getState().looping) deps.stopLoop(); + else return run('loop'); + } + + return { pending, validHex, byteCount, canSend, send: () => run('send'), toggleLoop }; +} diff --git a/src/features/send-panel/ui/SendOptions.vue b/src/features/send-panel/ui/SendOptions.vue new file mode 100644 index 0000000..9ce9f87 --- /dev/null +++ b/src/features/send-panel/ui/SendOptions.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/src/features/send-panel/ui/SendPanel.vue b/src/features/send-panel/ui/SendPanel.vue index 8083085..2d6b505 100644 --- a/src/features/send-panel/ui/SendPanel.vue +++ b/src/features/send-panel/ui/SendPanel.vue @@ -1,79 +1,73 @@ diff --git a/src/features/sessions/ui/SessionView.vue b/src/features/sessions/ui/SessionView.vue index 09cb264..80dd4f4 100644 --- a/src/features/sessions/ui/SessionView.vue +++ b/src/features/sessions/ui/SessionView.vue @@ -18,6 +18,8 @@ :capture-has-data="captureHasData" :is-connected="runtime.sessionLinkUp.value" :is-connecting="runtime.isConnecting.value" + :is-closing="runtime.isClosing.value" + :close-failed="runtime.closeFailed.value" :reconnecting="runtime.reconnecting.value" :error="runtime.error.value" :connection-conflict="runtime.connectionFailure.value?.conflict" @@ -438,6 +440,8 @@ function handleExportCancel() { diff --git a/src/features/sessions/ui/session-view-mode.ts b/src/features/sessions/ui/session-view-mode.ts new file mode 100644 index 0000000..4286cc0 --- /dev/null +++ b/src/features/sessions/ui/session-view-mode.ts @@ -0,0 +1 @@ +export type SessionViewMode = 'terminal' | 'waveform' | 'parser' | 'modbus' | 'shell' | 'mcumgr'; diff --git a/src/features/terminal/__tests__/mcumgr-hover-tip.test.ts b/src/features/terminal/__tests__/mcumgr-hover-tip.test.ts new file mode 100644 index 0000000..1ea1c35 --- /dev/null +++ b/src/features/terminal/__tests__/mcumgr-hover-tip.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { h } from 'vue'; +import { NTooltip } from 'naive-ui'; +import McumgrHoverTip from '../ui/mcumgr/McumgrHoverTip.vue'; + +enableAutoUnmount(afterEach); +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +function mountTip() { + const action = vi.fn(); + const keydown = vi.fn(); + const wrapper = mount(McumgrHoverTip, { + attachTo: document.body, + props: { text: 'Read image state' }, + slots: { default: () => h('button', { onClick: action, onKeydown: keydown }, 'Read') }, + }); + const trigger = wrapper.get('.mc-hover-tip'); + const tooltip = wrapper.getComponent(NTooltip); + return { wrapper, trigger, tooltip, action, keydown }; +} + +test('click dismisses visible help without swallowing the action and a new hover can reopen it', async () => { + const { wrapper, trigger, tooltip, action } = mountTip(); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(true); + + await wrapper.get('button').trigger('click'); + expect(action).toHaveBeenCalledOnce(); + expect(tooltip.props('show')).toBe(false); + + tooltip.vm.$emit('update:show', true); + await wrapper.vm.$nextTick(); + expect(tooltip.props('show')).toBe(false); + + await trigger.trigger('mouseleave'); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(true); +}); + +test('pointer activation cancels a pending hover reveal before the click action runs', async () => { + const { wrapper, trigger, tooltip, action } = mountTip(); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(100); + await wrapper.get('button').trigger('pointerdown'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(false); + expect(action).not.toHaveBeenCalled(); + + await wrapper.get('button').trigger('click'); + expect(action).toHaveBeenCalledOnce(); + expect(tooltip.props('show')).toBe(false); +}); + +test.each(['Enter', ' '])( + 'keyboard activation with %j dismisses help and preserves the slot event', + async (key) => { + const { wrapper, trigger, tooltip, keydown } = mountTip(); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(true); + + await wrapper.get('button').trigger('keydown', { key }); + expect(keydown).toHaveBeenCalledOnce(); + expect((keydown.mock.calls[0][0] as KeyboardEvent).defaultPrevented).toBe(false); + expect(tooltip.props('show')).toBe(false); + }, +); + +test('disabling help closes it without reopening when the command finishes under the pointer', async () => { + const { wrapper, trigger, tooltip } = mountTip(); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(true); + + await wrapper.setProps({ disabled: true }); + expect(tooltip.props('show')).toBe(false); + await wrapper.setProps({ disabled: false }); + tooltip.vm.$emit('update:show', true); + await wrapper.vm.$nextTick(); + expect(tooltip.props('show')).toBe(false); + + await trigger.trigger('mouseleave'); + await trigger.trigger('mouseenter'); + await vi.advanceTimersByTimeAsync(200); + expect(tooltip.props('show')).toBe(true); +}); diff --git a/src/features/terminal/__tests__/mcumgr-image-tab.test.ts b/src/features/terminal/__tests__/mcumgr-image-tab.test.ts index 668f479..ed3b22e 100644 --- a/src/features/terminal/__tests__/mcumgr-image-tab.test.ts +++ b/src/features/terminal/__tests__/mcumgr-image-tab.test.ts @@ -53,45 +53,41 @@ afterEach(() => { vi.unstubAllGlobals(); }); -test('image tab uses equal action tiles without an always-visible update blurb', () => { +test('image actions and the shared upgrade option remain discoverable', () => { const wrapper = mountTab(); - const text = wrapper.text(); - assert.match(text, /固件升级/); - assert.match(text, /镜像上传/); - assert.match(text, /查看/); - assert.match(text, /启动标记/); - assert.equal(wrapper.findAll('.mc-card').length, 4); - assert.equal(wrapper.findAll('.mc-action-tile').length, 2); - assert.equal(wrapper.find('.mc-card-copy').exists(), false); - assert.equal(text.includes(t('mcumgr.image.updateHint')), false); - assert.equal(text.includes(t('mcumgr.image.uploadHint')), false); - assert.equal(text.includes(t('mcumgr.image.updateCaption')), true); - assert.equal(text.includes(t('mcumgr.image.uploadCaption')), true); - assert.equal(text.includes('仅升级'), true); - assert.equal(text.includes('拒绝旧版本'), false); + for (const key of [ + 'mcumgr.image.update', + 'mcumgr.image.upload', + 'mcumgr.image.state', + 'mcumgr.image.slotInfo', + 'mcumgr.image.test', + 'mcumgr.image.confirm', + 'mcumgr.image.erase', + ]) { + buttonByLabel(wrapper, t(key)); + } + assert.equal(wrapper.text().includes(t('mcumgr.image.upgradeOnly')), true); + assert.equal(wrapper.find('[role="checkbox"]').exists(), true); }); -test('firmware actions use matching compact primary buttons', () => { +test('firmware upgrade is the primary action and image upload remains secondary', () => { const wrapper = mountTab(); const upgrade = buttonByLabel(wrapper, t('mcumgr.image.update')); const upload = buttonByLabel(wrapper, t('mcumgr.image.upload')); - const classOf = (node: ReturnType) => node.classes().join(' '); - assert.match(classOf(upgrade), /tiny/); - assert.match(classOf(upload), /tiny/); - assert.match(classOf(upgrade), /primary/); - assert.match(classOf(upload), /primary/); - - assert.equal(wrapper.find('.mc-action-content .mc-hover-tip-host.is-block').exists(), false); - assert.equal(/block/.test(classOf(upgrade)), false); - assert.equal(/block/.test(classOf(upload)), false); + assert.equal(upgrade.attributes('aria-label'), t('mcumgr.image.update')); + assert.equal(upload.attributes('aria-label'), t('mcumgr.image.upload')); + assert.equal(upgrade.classes().includes('transfer-action'), true); + assert.equal(upload.classes().includes('transfer-action'), true); + assert.equal(upgrade.classes().includes('is-primary'), true); + assert.equal(upload.classes().includes('is-primary'), false); + assert.equal(wrapper.text().includes(t('mcumgr.image.updateCaption')), true); + assert.equal(wrapper.text().includes(t('mcumgr.image.uploadCaption')), true); }); -test('every image action and section header exposes hover hint copy', () => { +test('image actions, hash entry, and version protection retain their hover help', () => { const wrapper = mountTab(); const hints = hintTexts(wrapper); for (const key of [ - 'mcumgr.group.inspectHint', - 'mcumgr.group.bootHint', 'mcumgr.image.updateHint', 'mcumgr.image.uploadHint', 'mcumgr.image.upgradeOnlyHint', diff --git a/src/features/terminal/__tests__/mcumgr-panel.test.ts b/src/features/terminal/__tests__/mcumgr-panel.test.ts new file mode 100644 index 0000000..78cb97f --- /dev/null +++ b/src/features/terminal/__tests__/mcumgr-panel.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment happy-dom +import { afterEach, expect, test, vi } from 'vitest'; +import { computed, defineComponent, h, ref } from 'vue'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; +import { NMessageProvider } from 'naive-ui'; +import McumgrPanel from '../ui/mcumgr/McumgrPanel.vue'; +import type { McumgrClientStatus } from '@/types'; +import type { SessionMcumgrController } from '@/features/sessions/application/use-session-mcumgr'; +import { t } from '@/lib/i18n'; + +enableAutoUnmount(afterEach); +afterEach(() => vi.unstubAllGlobals()); + +function fixture(result = '') { + const status = ref({ kind: 'idle' }); + const lastResult = ref(result); + const cancel = vi.fn(() => { + status.value = { kind: 'idle' }; + }); + const execute = vi.fn().mockResolvedValue(null); + const runOsEcho = vi.fn().mockResolvedValue(null); + const controller = { + busy: computed(() => status.value.kind === 'busy' || status.value.kind === 'progress'), + status, + lastResult, + cancel, + execute, + runOsEcho, + patchConfig: vi.fn(), + setResult: (value: string) => { + lastResult.value = value; + }, + } as unknown as SessionMcumgrController; + const wrapper = mount( + defineComponent({ + setup: () => () => + h(NMessageProvider, null, { + default: () => + h(McumgrPanel, { + sessionId: 'mcumgr-ui-test', + config: { + autoFrameSize: true, + frameSize: 512, + timeoutMs: 3000, + retries: 2, + shellHistory: [], + }, + isConnected: false, + mcumgr: controller, + }), + }), + }), + { attachTo: document.body }, + ); + return { wrapper, status, lastResult, cancel, execute, runOsEcho }; +} + +test('system tools preserve echo, query payloads, reset confirmation and busy guards', async () => { + const { wrapper, status, execute, runOsEcho } = fixture(); + const input = wrapper.get(`input[aria-label="${t('mcumgr.os.echo')}"]`); + await input.setValue('device-check'); + await input.trigger('keydown', { key: 'Enter' }); + expect(runOsEcho).toHaveBeenCalledExactlyOnceWith('device-check'); + for (const [label, action, op] of [ + ['tasks', 'tasks', { kind: 'os-tasks' }], + ['mpstat', 'mpstat', { kind: 'os-memory-pools' }], + ['datetime', 'datetime', { kind: 'os-datetime' }], + ['params', 'params', { kind: 'os-params' }], + ['info', 'info', { kind: 'os-info', format: null }], + ['bootloader', 'bootloader', { kind: 'os-bootloader-info' }], + ] as const) { + await wrapper.get(`button[aria-label="${t(`mcumgr.os.${label}`)}"]`).trigger('click'); + expect(execute).toHaveBeenLastCalledWith(action, op); + } + const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + vi.stubGlobal('confirm', confirm); + const reset = wrapper.findAll('.reset-row button')[0]; + await reset.trigger('click'); + expect(execute).toHaveBeenCalledTimes(6); + await reset.trigger('click'); + expect(confirm).toHaveBeenCalledWith(t('mcumgr.confirm.reset')); + expect(execute).toHaveBeenLastCalledWith('reset', { kind: 'os-reset', force: false }); + status.value = { kind: 'busy', action: 'tasks' }; + await flushPromises(); + expect(wrapper.get('.query-tool').attributes('disabled')).toBeDefined(); + await input.trigger('keydown', { key: 'Enter' }); + await reset.trigger('click'); + expect(execute).toHaveBeenCalledTimes(7); + expect(runOsEcho).toHaveBeenCalledTimes(1); +}); + +test('MCUMgr tabs support keyboard navigation and an executing transfer remains cancellable', async () => { + const { wrapper, status, cancel } = fixture(); + const tabs = wrapper.findAll('[role="tab"]'); + expect(tabs).toHaveLength(8); + for (const tab of tabs) + expect(document.getElementById(tab.attributes('aria-controls'))).not.toBeNull(); + await tabs[0].trigger('keydown', { key: 'ArrowRight' }); + await flushPromises(); + expect(tabs[1].attributes('aria-selected')).toBe('true'); + expect(document.activeElement).toBe(tabs[1].element); + expect(wrapper.get('[role="tabpanel"]:not([hidden])').attributes('aria-labelledby')).toBe( + tabs[1].attributes('id'), + ); + + status.value = { + kind: 'progress', + action: 'image-upload', + phase: 'upload', + offset: 25, + total: 100, + }; + await flushPromises(); + expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('25'); + const upgrade = wrapper.get(`button[aria-label="${t('mcumgr.image.update')}"]`); + expect(upgrade.attributes('disabled')).toBeDefined(); + await wrapper + .findAll('button') + .find((button) => button.text() === t('common.cancel'))! + .trigger('click'); + expect(cancel).toHaveBeenCalledOnce(); +}); + +test('MCUMgr results can wrap, expand, copy and clear without changing their raw content', async () => { + const text = '{\n "version": "1.2.3"\n}'; + const { wrapper, lastResult } = fixture(text); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + await wrapper.get(`button[aria-label="${t('mcumgr.result.wrap')}"]`).trigger('click'); + expect(wrapper.get('.mc-result').classes()).not.toContain('wrap'); + await wrapper.get(`button[aria-label="${t('mcumgr.result.expand')}"]`).trigger('click'); + expect(wrapper.get('.mc-workspace').classes()).toContain('result-expanded'); + await wrapper.get(`button[aria-label="${t('mcumgr.result.copy')}"]`).trigger('click'); + expect(writeText).toHaveBeenCalledExactlyOnceWith(text); + await wrapper.findAll('[role="tab"]')[2].trigger('click'); + expect(wrapper.get('.mc-workspace').classes()).not.toContain('result-expanded'); + expect(lastResult.value).toBe(text); + await wrapper.get(`button[aria-label="${t('common.clear')}"]`).trigger('click'); + expect(lastResult.value).toBe(''); + expect(wrapper.find('.mc-result-empty').exists()).toBe(true); +}); diff --git a/src/features/terminal/__tests__/packet-filter.test.ts b/src/features/terminal/__tests__/packet-filter.test.ts index c673846..1a8d917 100644 --- a/src/features/terminal/__tests__/packet-filter.test.ts +++ b/src/features/terminal/__tests__/packet-filter.test.ts @@ -23,6 +23,36 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +test.each(['FRAME', 'MERGED'] as const)( + '%s restores the retained search immediately after a session view remount', + (mode) => { + const retainedSearch = ref('alpha'); + const frames = ref([ + makeFrame('match', 'RX', [...new TextEncoder().encode('alpha')]), + makeFrame('other', 'TX', [...new TextEncoder().encode('beta')]), + ]); + for (let mount = 0; mount < 2; mount += 1) { + const scope = effectScope(); + const filter = scope.run(() => + usePacketFilter({ + frames, + searchInput: retainedSearch, + searchMode: ref('TEXT'), + packetViewMode: ref(mode), + getHexSearchData: (frame) => formatHex(frame.data).replace(/\s/g, '').toLowerCase(), + getTextSearchData: (frame) => formatUtf8(frame.data).toLowerCase(), + }), + )!; + assert.equal(filter.searchInput.value, 'alpha'); + assert.deepEqual( + filter.visibleFrames.value.map((frame) => frame.id), + [mode === 'FRAME' ? 'match' : 'merged-match'], + ); + scope.stop(); + } + }, +); + test('filters by direction and debounced text search', async () => { const scope = effectScope(); await scope.run(async () => { diff --git a/src/features/terminal/__tests__/use-packet-virtual-scroll-watch.test.ts b/src/features/terminal/__tests__/use-packet-virtual-scroll-watch.test.ts index fe335bf..f05764b 100644 --- a/src/features/terminal/__tests__/use-packet-virtual-scroll-watch.test.ts +++ b/src/features/terminal/__tests__/use-packet-virtual-scroll-watch.test.ts @@ -18,11 +18,16 @@ test('auto-scroll pins while parked at the tail, ignores frames while unpinned o }; try { const frames = ref(5); + const contentVersion = ref(0); const auto = ref(true); const captured: ReturnType[] = []; const Host = defineComponent({ setup() { - const api = usePacketVirtualScroll({ frameCount: frames, autoScroll: auto }); + const api = usePacketVirtualScroll({ + frameCount: frames, + autoScroll: auto, + contentVersion, + }); captured.push(api); return () => h('div'); }, @@ -47,6 +52,29 @@ test('auto-scroll pins while parked at the tail, ignores frames while unpinned o rafCallbacks[0]!(0); assert.equal(scroller.scrollTop, 2000, 'pin jumps to the tail'); + // Merged rows grow, and rolling retention replaces rows, at a fixed count. + rafCallbacks.length = 0; + contentVersion.value += 1; + await nextTick(); + assert.equal(rafCallbacks.length, 1, 'content changes pin even with the same row count'); + Object.defineProperty(scroller, 'scrollHeight', { value: 2200, configurable: true }); + rafCallbacks[0]!(0); + assert.equal(scroller.scrollTop, 2200); + Object.defineProperty(scroller, 'scrollHeight', { value: 2000, configurable: true }); + + // Disabling following before a queued callback runs must cancel its effect. + rafCallbacks.length = 0; + contentVersion.value += 1; + await nextTick(); + auto.value = false; + scroller.scrollTop = 1000; + rafCallbacks[0]!(0); + assert.equal(scroller.scrollTop, 1000, 'a queued pin respects the latest toggle'); + await nextTick(); + auto.value = true; + await nextTick(); + rafCallbacks.at(-1)!(0); + // Parked far above the bottom: frame arrivals schedule nothing. scroller.scrollTop = 0; api.onScroll(); diff --git a/src/features/terminal/application/use-packet-filter.ts b/src/features/terminal/application/use-packet-filter.ts index 38638d8..e66656d 100644 --- a/src/features/terminal/application/use-packet-filter.ts +++ b/src/features/terminal/application/use-packet-filter.ts @@ -44,7 +44,9 @@ export function usePacketFilter({ }: PacketFilterOptions) { const directionFilter = retainedDirectionFilter ?? ref('ALL'); const searchInput = retainedSearchInput ?? ref(''); - const searchQuery = ref(''); + // A retained search is already user input, not a new keystroke to debounce. + // Restore it immediately when the session's view is mounted again. + const searchQuery = ref(searchInput.value); let searchTimer: ReturnType | null = null; let cachedFiltered: readonly DataFrame[] = []; diff --git a/src/features/terminal/application/use-packet-virtual-scroll.ts b/src/features/terminal/application/use-packet-virtual-scroll.ts index 6470204..d26dd00 100644 --- a/src/features/terminal/application/use-packet-virtual-scroll.ts +++ b/src/features/terminal/application/use-packet-virtual-scroll.ts @@ -10,6 +10,8 @@ interface PacketVirtualScrollOptions { itemKey?: (index: number) => string | number; /** Invalidates cached estimates when row sizing settings/content change. */ rowSizeVersion?: Ref; + /** New content can arrive without changing the row count (merged/rolling views). */ + contentVersion?: Ref; } const ROW_HEIGHT = 28; @@ -53,6 +55,7 @@ export function usePacketVirtualScroll({ rowSize, itemKey, rowSizeVersion, + contentVersion, }: PacketVirtualScrollOptions) { const scrollRef = ref(null); const shouldAutoScroll = ref(true); @@ -114,6 +117,8 @@ export function usePacketVirtualScroll({ function pinToBottom() { autoScrollRafId = null; + // The user may scroll away or disable following after this RAF was queued. + if (!autoScroll.value || !shouldAutoScroll.value) return; const el = scrollRef.value; if (!el) return; // Instant jump (not smooth): during streaming, an animated scroll lags the @@ -122,7 +127,7 @@ export function usePacketVirtualScroll({ el.scrollTop = el.scrollHeight; } - watch(frameCount, () => { + watch([frameCount, () => contentVersion?.value], () => { if (!shouldAutoScroll.value || !autoScroll.value) return; if (autoScrollRafId !== null) return; // already scheduled — coalesce autoScrollRafId = requestAnimationFrame(pinToBottom); diff --git a/src/features/terminal/ui/DataPacketList.vue b/src/features/terminal/ui/DataPacketList.vue index 6bc40c9..7eac254 100644 --- a/src/features/terminal/ui/DataPacketList.vue +++ b/src/features/terminal/ui/DataPacketList.vue @@ -113,6 +113,7 @@ appStore.softWrapEnabled, appStore.showTimestamp, row.highlightClass, + row.highlightLabel, row.striped, row.frame.id === selectedFrameId, ]" @@ -333,6 +334,7 @@ const { packetRowHeight(visibleFrames.value[index], appStore.displayMode, preserveLineBreaks.value), itemKey: (index) => visibleFrames.value[index]?.id ?? index, rowSizeVersion, + contentVersion: framesVersion, }); const displayLabel = computed(() => @@ -397,6 +399,7 @@ function onKeydown(e: KeyboardEvent) { e.preventDefault(); const frame = frames.find((f) => f.id === selectedFrameId.value); if (frame) { + if (!canCopyFrames([frame])) return; const materialized = materializeFrame(frame); const text = packetKeyboardCopyText(materialized, formatFrame); navigator.clipboard.writeText(text).then( @@ -432,6 +435,7 @@ function onRowContextMenu(e: MouseEvent, frame: DataFrame) { async function handleCtxSelect(key: string) { ctxShow.value = false; if (!ctxFrame) return; + if (!canCopyFrames([ctxFrame])) return; const text = packetContextCopyText(key as PacketContextCopyKey, materializeFrame(ctxFrame), { formatFrame, stripAnsi, @@ -451,11 +455,7 @@ async function handleCopySelect(key: string) { const frames = mergedFilteredCopy ? visibleFrames.value : framesForPacketCopy(copyKey, props.frames, filteredFrames.value); - const { tooLarge } = packetCopySizeStatus(frames); - if (tooLarge) { - message.warning(t('packet.copyTooLarge')); - return; - } + if (!canCopyFrames(frames)) return; // A visible rope exposes only its 64 KiB display tail. Materialize it after // the size guard and only for the user-initiated filtered-copy action. const copyFrames = mergedFilteredCopy ? frames.map(materializeFrame) : frames; @@ -468,6 +468,12 @@ async function handleCopySelect(key: string) { } } +function canCopyFrames(frames: readonly DataFrame[]): boolean { + if (!packetCopySizeStatus(frames).tooLarge) return true; + message.warning(t('packet.copyTooLarge')); + return false; +} + onMounted(() => { const el = scrollRef.value; if (!el) return; diff --git a/src/features/terminal/ui/PacketRow.vue b/src/features/terminal/ui/PacketRow.vue index eeb0fd8..c7a8732 100644 --- a/src/features/terminal/ui/PacketRow.vue +++ b/src/features/terminal/ui/PacketRow.vue @@ -92,7 +92,7 @@ const dataTitle = computed(() => { ? preview : `${props.frame.omittedBytes.toLocaleString()} bytes omitted; ` + (preview ?? props.formatted); const meta = captureMeta.value; - return meta ? `${meta}\n${body}` : body; + return meta ? (body ? `${meta}\n${body}` : meta) : body; }); const formattedLines = computed(() => splitLogDisplayLines(props.formatted)); const formattedHtml = computed(() => diff --git a/src/features/terminal/ui/mcumgr/McumgrActionCard.vue b/src/features/terminal/ui/mcumgr/McumgrActionCard.vue new file mode 100644 index 0000000..4c12eff --- /dev/null +++ b/src/features/terminal/ui/mcumgr/McumgrActionCard.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/features/terminal/ui/mcumgr/McumgrActivityStrip.vue b/src/features/terminal/ui/mcumgr/McumgrActivityStrip.vue new file mode 100644 index 0000000..a7f3b7c --- /dev/null +++ b/src/features/terminal/ui/mcumgr/McumgrActivityStrip.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/features/terminal/ui/mcumgr/McumgrConfigTab.vue b/src/features/terminal/ui/mcumgr/McumgrConfigTab.vue index 5c21589..83abea1 100644 --- a/src/features/terminal/ui/mcumgr/McumgrConfigTab.vue +++ b/src/features/terminal/ui/mcumgr/McumgrConfigTab.vue @@ -1,26 +1,35 @@ @@ -92,6 +104,7 @@ import { computed } from 'vue'; import { NButton, NInput } from 'naive-ui'; import { t } from '@/lib/i18n'; +import McumgrActionCard from './McumgrActionCard.vue'; import type { SessionMcumgrController } from '@/features/sessions/application/use-session-mcumgr'; import type { McumgrOp } from '@/generated/ipc-contracts'; @@ -127,39 +140,35 @@ async function confirmRun(action: string, confirmMessage: string, op: McumgrOp): .mc-section { display: flex; flex-direction: column; - gap: 10px; + gap: var(--space-md); + min-width: 0; +} + +.mc-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: var(--space-lg); } -.mc-card { +.mc-field { display: flex; flex-direction: column; - gap: 8px; - padding: 10px 12px; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: linear-gradient(180deg, var(--surface-lift), transparent), var(--bg-secondary); - box-shadow: var(--shadow-sm); + gap: var(--space-sm); + min-width: 0; } -.mc-card-head { - font-size: var(--font-size-2xs); - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-dim); - font-weight: 600; - white-space: nowrap; +.mc-field-label { + color: var(--text-secondary); + font-size: var(--font-size-data); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-normal); } -.mc-row, -.mc-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; +.mc-field :deep(.n-input__input-el) { + font-size: var(--font-size-data); } -.mc-grow { - flex: 1; - min-width: 140px; +.mc-delete-action { + margin-inline-start: auto; } diff --git a/src/features/terminal/ui/mcumgr/McumgrFileSystemTab.vue b/src/features/terminal/ui/mcumgr/McumgrFileSystemTab.vue index f885195..ca576f9 100644 --- a/src/features/terminal/ui/mcumgr/McumgrFileSystemTab.vue +++ b/src/features/terminal/ui/mcumgr/McumgrFileSystemTab.vue @@ -1,19 +1,20 @@ @@ -54,6 +74,7 @@ import { computed } from 'vue'; import { NButton, NInput } from 'naive-ui'; import { Download, Upload } from '@lucide/vue'; import { t } from '@/lib/i18n'; +import McumgrActionCard from './McumgrActionCard.vue'; import type { SessionMcumgrController } from '@/features/sessions/application/use-session-mcumgr'; const props = defineProps<{ @@ -84,39 +105,30 @@ async function onDownload(): Promise { .mc-section { display: flex; flex-direction: column; - gap: 10px; + gap: var(--space-md); + min-width: 0; } -.mc-card { +.mc-field { display: flex; flex-direction: column; - gap: 8px; - padding: 10px 12px; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: linear-gradient(180deg, var(--surface-lift), transparent), var(--bg-secondary); - box-shadow: var(--shadow-sm); + gap: var(--space-sm); + min-width: 0; } -.mc-card-head { - font-size: var(--font-size-2xs); - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-dim); - font-weight: 600; - white-space: nowrap; +.mc-field-label { + color: var(--text-secondary); + font-size: var(--font-size-data); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-normal); } -.mc-row, -.mc-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; +.mc-path-input :deep(.n-input__input-el) { + font-family: var(--font-mono); + font-size: var(--font-size-data); } -.mc-grow { - flex: 1; - min-width: 140px; +.mc-close-action { + margin-inline-start: auto; } diff --git a/src/features/terminal/ui/mcumgr/McumgrHoverTip.vue b/src/features/terminal/ui/mcumgr/McumgrHoverTip.vue index 0266e3b..99e80f3 100644 --- a/src/features/terminal/ui/mcumgr/McumgrHoverTip.vue +++ b/src/features/terminal/ui/mcumgr/McumgrHoverTip.vue @@ -1,8 +1,23 @@ diff --git a/src/features/terminal/ui/mcumgr/McumgrPanel.vue b/src/features/terminal/ui/mcumgr/McumgrPanel.vue index c8e253d..4b8245d 100644 --- a/src/features/terminal/ui/mcumgr/McumgrPanel.vue +++ b/src/features/terminal/ui/mcumgr/McumgrPanel.vue @@ -6,43 +6,45 @@ --> diff --git a/src/features/terminal/ui/mcumgr/McumgrProgressBar.vue b/src/features/terminal/ui/mcumgr/McumgrProgressBar.vue index 4f8febe..fb0bf47 100644 --- a/src/features/terminal/ui/mcumgr/McumgrProgressBar.vue +++ b/src/features/terminal/ui/mcumgr/McumgrProgressBar.vue @@ -1,29 +1,50 @@ diff --git a/src/features/terminal/ui/mcumgr/McumgrResultPanel.vue b/src/features/terminal/ui/mcumgr/McumgrResultPanel.vue new file mode 100644 index 0000000..0f79b71 --- /dev/null +++ b/src/features/terminal/ui/mcumgr/McumgrResultPanel.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/features/terminal/ui/mcumgr/McumgrSystemTab.vue b/src/features/terminal/ui/mcumgr/McumgrSystemTab.vue new file mode 100644 index 0000000..9798df0 --- /dev/null +++ b/src/features/terminal/ui/mcumgr/McumgrSystemTab.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/src/features/terminal/ui/mcumgr/McumgrTabs.vue b/src/features/terminal/ui/mcumgr/McumgrTabs.vue new file mode 100644 index 0000000..f327258 --- /dev/null +++ b/src/features/terminal/ui/mcumgr/McumgrTabs.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/features/workspace/__tests__/use-export.test.ts b/src/features/workspace/__tests__/use-export.test.ts index bb502c1..6559469 100644 --- a/src/features/workspace/__tests__/use-export.test.ts +++ b/src/features/workspace/__tests__/use-export.test.ts @@ -757,6 +757,85 @@ test('useExport: raw arrays stop before retaining a frame beyond the reference c // ---- DB-sourced (workspace-frames) mode ---- +test('useExport: a repeated invocation cannot replace the active export cancellation state', async () => { + const target = deferredTarget(); + const revoked: string[] = []; + let targetRequests = 0; + const api = useExport({ + requestTarget: () => { + targetRequests += 1; + return target.promise; + }, + revokeTarget: async (token) => { + revoked.push(token); + }, + }); + const exporting = api.exportData([frame('RX', [1])], 'bin', 'HEX'); + api.cancelExport(); + const repeated = await api.exportData([frame('TX', [2])], 'bin', 'HEX'); + assert.equal(repeated.ok, false); + assert.equal(api.isExporting.value, true); + assert.equal(targetRequests, 1); + target.resolve({ token: 'cancelled-grant', displayName: 'capture.bin' }); + assert.deepEqual(await exporting, { ok: false, cancelled: true }); + assert.deepEqual(revoked, ['cancelled-grant']); +}); + +test('useExport: cancellation during DB preparation revokes the grant without opening a native export', async () => { + const revoked: string[] = []; + let begins = 0; + const api = useExport({ + sessionId: 'session-main', + requestTarget: async () => ({ token: 'prepared-grant', displayName: 'capture.bin' }), + revokeTarget: async (token) => { + revoked.push(token); + }, + dbSource: { + async prepare() { + api.cancelExport(); + return { workspaceId: 'workspace-main', toSeqExclusive: 1 }; + }, + }, + sessionClient: { + begin: async () => { + begins += 1; + return { exportId: 'native-export' }; + }, + append: async () => ({ totalFrames: 1, totalRawBytes: 1 }), + finish: async () => ({ frames: 1, rawBytes: 1, outputBytes: 1, durationMs: 1 }), + abort: async () => {}, + }, + }); + assert.deepEqual(await api.exportData([frame('RX', [1])], 'bin', 'HEX', { unfiltered: true }), { + ok: false, + cancelled: true, + }); + assert.equal(begins, 0); + assert.deepEqual(revoked, ['prepared-grant']); +}); + +test('useExport: a failed DB preparation revokes the unused grant and preserves the error', async () => { + const revoked: string[] = []; + const api = useExport({ + sessionId: 'session-main', + requestTarget: async () => ({ token: 'unused-grant', displayName: 'capture.bin' }), + revokeTarget: async (token) => { + revoked.push(token); + throw new Error('cleanup failed'); + }, + dbSource: { + async prepare() { + throw new Error('source preparation failed'); + }, + }, + }); + const result = await api.exportData([frame('RX', [1])], 'bin', 'HEX', { unfiltered: true }); + assert.equal(result.ok, false); + assert.match(result.ok ? '' : (result.error ?? ''), /source preparation failed/); + assert.deepEqual(revoked, ['unused-grant']); + assert.equal(api.isExporting.value, false); +}); + function recordingDbSource( selection: { workspaceId: string; toSeqExclusive: number } | null, ): WorkspaceDbExportSource & { prepared: string[] } { diff --git a/src/features/workspace/__tests__/workspace-application-service.test.ts b/src/features/workspace/__tests__/workspace-application-service.test.ts index 98d3b20..e5391a6 100644 --- a/src/features/workspace/__tests__/workspace-application-service.test.ts +++ b/src/features/workspace/__tests__/workspace-application-service.test.ts @@ -879,6 +879,35 @@ test('logical byte partitioning keeps mutations whole and rejects one oversized assert.equal(oversized.application.snapshot().unsavedMutationCount, 1); }); +test('logical byte partitioning accounts for UTF-8 and escaped JSON strings and keys', async () => { + const system = createSystem([definition('workspace', 0)]); + assert.equal((await system.application.openWorkspace('workspace')).outcome, 'completed'); + const value = '中😀"\\\u0000\n'.repeat(16 * 1024); + const key = '中"'.repeat(1024); + assert.deepEqual( + system.application.queueConfigMutations( + ['first', 'second'].map((id) => ({ + kind: 'upsert-feature-state' as const, + entityId: `plugin:${id}`, + payload: { feature: 'plugin', state: { [key]: value } }, + })), + ), + { accepted: true }, + ); + assert.equal((await system.application.flush()).outcome, 'completed'); + assert.deepEqual( + system.applyRequests.map((request) => request.mutations.length), + [1, 1], + ); + for (const request of system.applyRequests) { + const actualBytes = request.mutations.reduce( + (total, mutation) => total + new TextEncoder().encode(JSON.stringify(mutation)).length, + 0, + ); + assert.ok(actualBytes <= IPC_LIMITS.MAX_WORKSPACE_BATCH_BYTES); + } +}); + test('config and frame autosave obey the fixed 300 ms and 250 ms/256/512 KiB gates', async () => { vi.useFakeTimers(); try { diff --git a/src/features/workspace/__tests__/workspace-transition-coordinator.test.ts b/src/features/workspace/__tests__/workspace-transition-coordinator.test.ts index 218fbbb..c716cf0 100644 --- a/src/features/workspace/__tests__/workspace-transition-coordinator.test.ts +++ b/src/features/workspace/__tests__/workspace-transition-coordinator.test.ts @@ -58,6 +58,36 @@ describe('WorkspaceTransitionCoordinator', () => { expect(calls).toEqual(['session:quiesce']); }); + it('restores a participant that partially quiesced before rejecting', async () => { + const calls: string[] = []; + const coordinator = new WorkspaceTransitionCoordinator([ + participant('first', calls), + { + ...participant('failing', calls), + async quiesce() { + calls.push('failing:quiesce'); + throw new Error('drain failed after stopping resources'); + }, + }, + participant('untouched', calls), + ]); + await expect( + coordinator.quiesce({ + transitionId: 'partial', + previousWorkspaceId: 'old', + persistence: {} as never, + }), + ).rejects.toThrow('drain failed'); + const restoreContext = { + transitionId: 'partial', + previousWorkspaceId: 'old', + failedWorkspaceId: null, + }; + await coordinator.restore(restoreContext); + await coordinator.restore(restoreContext); + expect(calls).toEqual(['first:quiesce', 'failing:quiesce', 'failing:restore', 'first:restore']); + }); + it('continues reverse restore and reports all failures', async () => { const restoreFirst = vi.fn(() => { throw new Error('first'); diff --git a/src/features/workspace/application/save-queues/index.ts b/src/features/workspace/application/save-queues/index.ts index c14adec..ff62155 100644 --- a/src/features/workspace/application/save-queues/index.ts +++ b/src/features/workspace/application/save-queues/index.ts @@ -197,14 +197,14 @@ function workspaceMutationLogicalWeight( // Every other structured variant carries an unbounded JSON document // (feature state, session documents, AI messages, metadata), so its size // cannot be bounded by field counts alone. Walk the enumerable fields once - // with pure arithmetic — strings and keys at face length, widest decimal + // with pure arithmetic — UTF-8/JSON string bytes, widest decimal // form per number — which costs no serialization allocations. let bytes = 40 + command.kind.length; for (const [key, value] of Object.entries(command)) { if (key === 'kind') continue; const valueBytes = estimateStructuredJsonBytes(value, 0); if (valueBytes === null) return null; - bytes += key.length + 5 + valueBytes + 1; + bytes += jsonStringBytes(key) + 3 + valueBytes + 1; if (!Number.isSafeInteger(bytes)) return null; } return { bytes, frames: 0, singleLargeFrame: false }; @@ -220,13 +220,13 @@ function estimateStructuredJsonBytes(value: unknown, depth: number): number | nu if (value === null) return 4; switch (typeof value) { case 'undefined': - return 0; // JSON.stringify omits the key entirely + return 4; // Array entries serialize as null; object properties are omitted. case 'boolean': return 5; case 'number': return Number.isFinite(value) ? 24 : 4; case 'string': - return value.length + 2; + return jsonStringBytes(value); case 'object': break; case 'bigint': @@ -250,8 +250,36 @@ function estimateStructuredJsonBytes(value: unknown, depth: number): number | nu for (const [key, item] of Object.entries(value)) { const itemBytes = estimateStructuredJsonBytes(item, depth + 1); if (itemBytes === null) return null; - bytes += key.length + 4 + itemBytes + 1; + bytes += jsonStringBytes(key) + 2 + itemBytes + 1; if (!Number.isSafeInteger(bytes)) return null; } return bytes; } + +/** UTF-8 byte count after JSON escaping, without allocating an encoded copy. */ +function jsonStringBytes(value: string): number { + let bytes = 2; // Surrounding quotes. + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) { + bytes += 2; + } else if (code < 0x20) { + bytes += code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : 6; + } else if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdfff) { + const next = value.charCodeAt(index + 1); + if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 6; // JSON.stringify escapes an unpaired surrogate as \uXXXX. + } + } else { + bytes += 3; + } + } + return bytes; +} diff --git a/src/features/workspace/application/use-export.ts b/src/features/workspace/application/use-export.ts index aad3277..631c9ce 100644 --- a/src/features/workspace/application/use-export.ts +++ b/src/features/workspace/application/use-export.ts @@ -177,9 +177,11 @@ export function useExport(deps: UseExportDeps = {}) { displayMode: DisplayMode, options: ExportDataOptions = {}, ): Promise { + if (isExporting.value) return { ok: false, error: t('error.busy') }; isExporting.value = true; cancelRequested = false; progress.value = emptyProgress(); + let unconsumedTargetGrant: string | null = null; try { const operationBinding = operations ? { @@ -220,13 +222,13 @@ export function useExport(deps: UseExportDeps = {}) { format, deps.requestTarget ?? requestSaveTarget, ); + unconsumedTargetGrant = targetGrant; if (!targetGrant) { progress.value.phase = 'cancelled'; await cancelRegisteredOperation(operations, activeOperationId); return { ok: false, cancelled: true }; } if (cancelRequested) { - await (deps.revokeTarget ?? revokeFileGrant)(targetGrant).catch(() => undefined); progress.value.phase = 'cancelled'; await cancelRegisteredOperation(operations, activeOperationId); return { ok: false, cancelled: true }; @@ -249,6 +251,7 @@ export function useExport(deps: UseExportDeps = {}) { }; } } + if (cancelRequested) throw new ExportCancelledError('export cancelled'); const { stats, divergence } = await exportWithSession( source.iterate(), @@ -273,6 +276,7 @@ export function useExport(deps: UseExportDeps = {}) { progress.value.phase = 'finishing'; }, (_exportId, abort) => { + unconsumedTargetGrant = null; abortNative = abort; }, exportSource, @@ -310,6 +314,12 @@ export function useExport(deps: UseExportDeps = {}) { // of a generic toast. The serialized AppError is { type, details: { message } }. return { ok: false, error: getCommandErrorMessage(e, t('message.exportFallbackFailed')) }; } finally { + if (unconsumedTargetGrant) { + const grant = unconsumedTargetGrant; + await Promise.resolve() + .then(() => (deps.revokeTarget ?? revokeFileGrant)(grant)) + .catch(() => undefined); + } isExporting.value = false; activeOperationId = null; abortNative = null; diff --git a/src/features/workspace/application/workspace-transition-coordinator.ts b/src/features/workspace/application/workspace-transition-coordinator.ts index 9738537..6c9705f 100644 --- a/src/features/workspace/application/workspace-transition-coordinator.ts +++ b/src/features/workspace/application/workspace-transition-coordinator.ts @@ -26,6 +26,7 @@ export interface SessionRuntimeStatusTransitionPort { } interface ParticipantProgress { + quiesceStarted: Set; quiesced: Set; disposed: Set; activated: Set; @@ -51,6 +52,9 @@ export class WorkspaceTransitionCoordinator implements WorkspaceRuntimeLifecycle const progress = this.forTransition(context.transitionId); for (const participant of this.participants) { if (progress.quiesced.has(participant.id)) continue; + // A participant can stop some resources before its async drain rejects. + // Include it in rollback even when the full phase did not complete. + progress.quiesceStarted.add(participant.id); await participant.quiesce(context); progress.quiesced.add(participant.id); } @@ -71,7 +75,7 @@ export class WorkspaceTransitionCoordinator implements WorkspaceRuntimeLifecycle for (const participant of [...this.participants].reverse()) { if ( progress.restored.has(participant.id) || - (!progress.quiesced.has(participant.id) && !progress.disposed.has(participant.id)) + (!progress.quiesceStarted.has(participant.id) && !progress.disposed.has(participant.id)) ) { continue; } @@ -110,6 +114,7 @@ export class WorkspaceTransitionCoordinator implements WorkspaceRuntimeLifecycle let progress = this.progress.get(transitionId); if (!progress) { progress = { + quiesceStarted: new Set(), quiesced: new Set(), disposed: new Set(), activated: new Set(), diff --git a/src/features/workspace/ui/WorkspacePanel.vue b/src/features/workspace/ui/WorkspacePanel.vue index 624cacd..f3517bc 100644 --- a/src/features/workspace/ui/WorkspacePanel.vue +++ b/src/features/workspace/ui/WorkspacePanel.vue @@ -1,8 +1,15 @@