From bc4c526236fdad274c2c7e186f1c1315139fe5c4 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 22 Sep 2026 19:56:30 +0800 Subject: [PATCH] perf: update keyboard --- DESIGN.md | 7 + ui/app.vue | 4 +- ui/assets/css/tokens.css | 7 + ui/components/ConnectForm/connectForm.vue | 56 +++-- ui/components/ConnectForm/fields.vue | 28 +-- ui/components/Modal/connectionFormModal.vue | 64 +++-- .../Workspace/connectionSetupPane.vue | 80 +++--- ui/components/Workspace/statusFooter.vue | 8 - ui/components/Workspace/subTabStrip.vue | 5 +- ui/components/Workspace/tabHeader.test.ts | 110 ++++++++ ui/components/Workspace/tabHeader.vue | 23 +- .../Workspace/virtualKeyboardPopover.test.ts | 230 ++++++++++++++++- .../Workspace/virtualKeyboardPopover.vue | 237 +++++++++++++++--- ui/composables/useMobile.test.ts | 54 ++++ ui/composables/useMobile.ts | 8 + .../composables/terminal/useTerminalSocket.ts | 22 +- .../tests/terminal/terminalRenderer.test.ts | 64 +++++ ui/koko/utils/terminalRenderer.ts | 28 +++ 18 files changed, 845 insertions(+), 190 deletions(-) create mode 100644 ui/components/Workspace/tabHeader.test.ts create mode 100644 ui/composables/useMobile.test.ts create mode 100644 ui/composables/useMobile.ts create mode 100644 ui/koko/tests/terminal/terminalRenderer.test.ts create mode 100644 ui/koko/utils/terminalRenderer.ts diff --git a/DESIGN.md b/DESIGN.md index f4316f2c5..b7cda5687 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -39,6 +39,13 @@ This document defines a unification plan: protocol handling converges into `koko - Nuxt UI based screens should consume Nuxt UI components first and avoid custom theme branching unless a component is not expressive enough - custom connector/workspace UIs must not infer colors from `primary` or a single background color; they should only consume semantic tokens such as surface, text, border, hover, selected, and focus +### Mobile layout + +- `useMobile()` shares the reactive layout mode; `app.vue` publishes it as `body.mobile`. +- Mobile layout requires a coarse primary pointer with no hover and a viewport width of at most 767px or height of at most 600px. Desktop resizing alone does not activate it. +- Use `body.mobile` for mobile styles, including scoped component styles and teleported content; use `useMobile()` for UI behavior. Width-only responsive rules may still adapt constrained desktop layouts. +- Session and nested tab strips use `--workspace-session-tab-width` and `--workspace-sub-tab-width` for both their ideal strip width and each tab's preferred width, keeping the create control next to the last tab. + ## Theme System The shared workspace should use one theme pipeline for desktop shell UI and connector workspaces. diff --git a/ui/app.vue b/ui/app.vue index e839daa2f..18190dae1 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -40,6 +40,7 @@ const webWorkspaceBrand = useState(WORKSPACE_BRAND_STATE_KEY, () => COMM const webWorkspaceFavicon = useState(WORKSPACE_FAVICON_STATE_KEY, () => ""); const { isMacOS, isWindows } = usePlatform(); +const isMobile = useMobile(); const { locale, setLocale, t } = useI18n(); const uiLocale = computed(() => getUiLocale(locale.value)); watch(locale, (value) => agentClient.setResponseLanguage(normalizeLanguageCode(value)), { @@ -128,7 +129,8 @@ useHead({ }), bodyAttrs: { class: computed( - () => `${platformClass.value} ${micaClass.value} ${vibrancyClass.value} font-sans antialiased h-screen w-screen` + () => + `${platformClass.value} ${micaClass.value} ${vibrancyClass.value} ${isMobile.value ? "mobile" : ""} font-sans antialiased h-screen w-screen` ), style: computed( () => ` diff --git a/ui/assets/css/tokens.css b/ui/assets/css/tokens.css index 4b78c9d76..99883112b 100644 --- a/ui/assets/css/tokens.css +++ b/ui/assets/css/tokens.css @@ -117,6 +117,8 @@ --data-grid-text: var(--app-text-primary); --data-grid-text-muted: var(--app-text-muted); --workspace-toolbar-height: 38px; + --workspace-session-tab-width: 176px; + --workspace-sub-tab-width: 160px; --workspace-tab-group-accent: var(--color-bg-accent); --workspace-tab-group-foreground: var(--app-accent-foreground); --workspace-surface-background: var(--app-surface-canvas); @@ -210,6 +212,11 @@ --replay-shadow: var(--theme-shadow-soft); } +body.mobile { + --workspace-session-tab-width: 128px; + --workspace-sub-tab-width: 120px; +} + .dark { --app-border-card: color-mix(in srgb, var(--theme-fg) 34%, transparent); --app-ai-icon-gradient: linear-gradient( diff --git a/ui/components/ConnectForm/connectForm.vue b/ui/components/ConnectForm/connectForm.vue index 1076bb9a9..68daabaaf 100644 --- a/ui/components/ConnectForm/connectForm.vue +++ b/ui/components/ConnectForm/connectForm.vue @@ -309,38 +309,40 @@ watch( background: var(--theme-accent); } -@media (max-width: 767px), (max-height: 600px) { - .connect-form { - flex: 1; - gap: 0.5rem; - overflow: hidden; - } +body.mobile .connect-form { + flex: 1; + gap: 0.5rem; + overflow: hidden; +} - .protocol-tabs { - flex: 1; - overscroll-behavior-x: contain; - } +body.mobile .protocol-tabs { + flex: 1; + overscroll-behavior-x: contain; +} - .protocol-tab-button, - .connect-form--island .protocol-tab-button { - min-height: 44px; - padding-inline: 12px; - } +body.mobile .protocol-tab-button, +body.mobile .connect-form--island .protocol-tab-button { + min-height: 44px; + padding-inline: 12px; +} - .connect-form-body { - min-height: 0; - overflow-y: auto; - overscroll-behavior-y: contain; - padding: 2px 2px 8px; - scroll-padding-block: 8px; - } +body.mobile .connect-form-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 2px 2px 8px; + scroll-padding-block: 8px; +} - .connect-form-controls { - gap: 0.75rem; - } +body.mobile .connect-form-controls { + gap: 0.75rem; +} - .connect-form-controls :deep(input:not([type="checkbox"]):not([type="radio"])), - .connect-form-controls :deep(button[role="combobox"]) { +@media (max-width: 767px) { + body.mobile .connect-form-controls :deep(input:not([type="checkbox"]):not([type="radio"])), + body.mobile .connect-form-controls :deep(button[role="combobox"]), + body.mobile .connect-form-controls :deep(button[aria-haspopup="listbox"]), + body.mobile .connect-form-controls :deep(.remember-secret-button) { min-height: 44px; } } diff --git a/ui/components/ConnectForm/fields.vue b/ui/components/ConnectForm/fields.vue index c0ce7609e..c10ce1e30 100644 --- a/ui/components/ConnectForm/fields.vue +++ b/ui/components/ConnectForm/fields.vue @@ -331,22 +331,20 @@ watchDebounced( display: none; } -@media (max-width: 767px), (max-height: 600px) { - .connection-form-fields { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - overflow: hidden; - } +body.mobile .connection-form-fields { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; +} - .connection-form-mobile-submit { - display: inline-flex; - } +body.mobile .connection-form-mobile-submit { + display: inline-flex; +} - .connection-form-remember, - .connection-form-actions { - margin-top: 0.75rem; - } +body.mobile .connection-form-remember, +body.mobile .connection-form-actions { + margin-top: 0.75rem; } diff --git a/ui/components/Modal/connectionFormModal.vue b/ui/components/Modal/connectionFormModal.vue index cbd95164e..de313ca90 100644 --- a/ui/components/Modal/connectionFormModal.vue +++ b/ui/components/Modal/connectionFormModal.vue @@ -138,43 +138,41 @@ watch( display: none; } -@media (max-width: 767px), (max-height: 600px) { - .connection-form-modal-action-target { - display: flex; - flex-shrink: 0; - order: -1; - margin-inline-end: 4px; - } +body.mobile .connection-form-modal-action-target { + display: flex; + flex-shrink: 0; + order: -1; + margin-inline-end: 4px; +} - .connection-form-modal { - width: calc(100vw - 1rem); - height: calc(100dvh - 1rem); - max-height: calc(100dvh - 1rem); - } +body.mobile .connection-form-modal { + width: calc(100vw - 1rem); + height: calc(100dvh - 1rem); + max-height: calc(100dvh - 1rem); +} - .connection-form-modal-body, - .connection-form-modal-fields { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - overflow: hidden; - } +body.mobile .connection-form-modal-body, +body.mobile .connection-form-modal-fields { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; +} - .connection-form-modal-body { - padding: 4px 12px 8px; - } +body.mobile .connection-form-modal-body { + padding: 4px 12px 8px; +} - .connection-form-modal-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +body.mobile .connection-form-modal-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - .connection-form-modal-close { - position: static; - flex-shrink: 0; - margin-inline-start: 4px; - } +body.mobile .connection-form-modal-close { + position: static; + flex-shrink: 0; + margin-inline-start: 4px; } diff --git a/ui/components/Workspace/connectionSetupPane.vue b/ui/components/Workspace/connectionSetupPane.vue index 7f49f9cb4..9808772a1 100644 --- a/ui/components/Workspace/connectionSetupPane.vue +++ b/ui/components/Workspace/connectionSetupPane.vue @@ -546,54 +546,52 @@ onMounted(loadAsset); 0 16px 36px color-mix(in srgb, var(--app-fg) 5%, transparent); } -@media (max-width: 767px), (max-height: 600px) { - .connection-setup-stage { - overflow: hidden; - padding: 8px; - } +body.mobile .connection-setup-stage { + overflow: hidden; + padding: 8px; +} - .connection-setup-content, - .connection-setup-group { - height: 100%; - min-height: 0; - } +body.mobile .connection-setup-content, +body.mobile .connection-setup-group { + height: 100%; + min-height: 0; +} - .connection-setup-group { - width: min(768px, 100%); - } +body.mobile .connection-setup-group { + width: min(768px, 100%); +} - .connection-setup-shell { - display: flex; - height: 100%; - min-height: 0; - flex-direction: column; - } +body.mobile .connection-setup-shell { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; +} - .connection-setup-header { - height: 44px; - padding-inline: 12px; - touch-action: auto; - cursor: default; - } +body.mobile .connection-setup-header { + height: 44px; + padding-inline: 12px; + touch-action: auto; + cursor: default; +} - .connection-setup-close { - min-width: 44px; - min-height: 44px; - } +body.mobile .connection-setup-close { + min-width: 44px; + min-height: 44px; +} - .connection-setup-body { - flex: 1; - min-height: 0; - } +body.mobile .connection-setup-body { + flex: 1; + min-height: 0; +} - .connection-setup-form { - padding: 4px 12px 8px; - } +body.mobile .connection-setup-form { + padding: 4px 12px 8px; +} - .connection-setup-form--fields { - display: flex; - overflow: hidden; - flex-direction: column; - } +body.mobile .connection-setup-form--fields { + display: flex; + overflow: hidden; + flex-direction: column; } diff --git a/ui/components/Workspace/statusFooter.vue b/ui/components/Workspace/statusFooter.vue index dc962e652..173e99170 100644 --- a/ui/components/Workspace/statusFooter.vue +++ b/ui/components/Workspace/statusFooter.vue @@ -179,11 +179,3 @@ onBeforeUnmount(() => { - - diff --git a/ui/components/Workspace/subTabStrip.vue b/ui/components/Workspace/subTabStrip.vue index 0840ddb81..791810409 100644 --- a/ui/components/Workspace/subTabStrip.vue +++ b/ui/components/Workspace/subTabStrip.vue @@ -48,11 +48,10 @@ const localDraggedId = ref(""); const dropTargetId = ref(""); const dropPlacement = ref<"before" | "after">("before"); const editorTabMime = "application/x-jumpserver-editor-tab"; -const SUB_TAB_MAX_WIDTH = 160; const SUB_TAB_GAP = 4; const tabStripIdealWidth = computed(() => { const count = props.tabs.length; - return `${count * SUB_TAB_MAX_WIDTH + Math.max(0, count - 1) * SUB_TAB_GAP}px`; + return `calc(${count} * var(--workspace-sub-tab-width) + ${Math.max(0, count - 1) * SUB_TAB_GAP}px)`; }); const tabMenuItems = computed(() => props.tabs.map((tab) => ({ @@ -235,7 +234,7 @@ watch(
= {}) { + const { descriptor } = parse(source); + const script = compileScript(descriptor, { id: "tab-header-test", inlineTemplate: true }); + const { outputText } = ts.transpileModule(script.content, { + compilerOptions: { module: ts.ModuleKind.CommonJS } + }); + const scope = { ...Vue, ...services }; + return new Function("require", ...Object.keys(scope), `const exports = {};\n${outputText}\nreturn exports.default;`)( + (name: string) => (name === "vue" ? Vue : scope), + ...Object.values(scope) + ); +} + +it.skipIf(typeof document === "undefined").each([false, true])( + "preserves group icons and excludes hidden tabs from width (standalone: %s)", + async (standalone) => { + const group = Vue.reactive({ id: "servers", title: "Servers", collapsed: false }); + const tabs = Vue.ref([ + { id: "linux", assetName: "Linux", assetType: "linux", group }, + { id: "mysql", assetName: "MySQL", assetType: "mysql", group }, + { id: "other", assetName: "Other", assetType: "windows" } + ]); + const activeTabId = Vue.ref("mysql"); + const component = compileComponent(source, { + useResizeObserver, + sessionGroupSaveError: Vue.ref(""), + resolveAssetIconFromFields, + useUserInfoStore: () => ({ loggedIn: Vue.ref(true) }), + storeToRefs: (store: unknown) => store, + useI18n: () => ({ t: (key: string) => key }), + useToast: () => ({ add: vi.fn() }), + useRuntimeConfig: () => ({ app: { baseURL: "/" } }), + usePlatform: () => ({ isMacOS: Vue.ref(false) }), + useSettingsWindow: () => ({ open: Vue.ref(false) }), + useWorkspaceTabs: () => ({ + tabs, + activeTabId, + activeTab: Vue.computed(() => tabs.value.find((tab) => tab.id === activeTabId.value)), + tabGroups: Vue.ref([group]), + draggedTabId: Vue.ref(""), + toggleTabGroup: () => (group.collapsed = !group.collapsed) + }), + useWorkspaceTabMenu: () => ({}), + useEventListener: vi.fn(), + isDesktopRuntime: () => false + }); + const host = document.createElement("div"); + host.style.setProperty("--workspace-session-tab-width", "176px"); + document.body.append(host); + const app = Vue.createApp(component, { standalone }); + app.component("AppAssetIcon", compileComponent(iconSource)); + app.component("UIcon", { render: () => Vue.h("svg") }); + app.component( + "UButton", + Vue.defineComponent({ + setup: + (_props, { slots }) => + () => + Vue.h("button", slots.default?.()) + }) + ); + app.component( + "UTooltip", + Vue.defineComponent({ + setup: + (_props, { slots }) => + () => + slots.default?.() + }) + ); + app.component("WorkspaceAddSessionPopover", { render: () => Vue.h("button", "+") }); + for (const name of ["UDropdownMenu", "UModal", "UInput"]) app.component(name, { render: () => null }); + app.mount(host); + try { + await Vue.nextTick(); + const images = [...host.querySelectorAll("img")]; + expect(images).toHaveLength(3); + const capsule = host.querySelector(".workspace-tab-capsule")!; + const expandedWidth = getComputedStyle(capsule).width; + for (let i = 0; i < 3; i++) { + const label = host.querySelector(".workspace-tab-group-label"); + if (label) label.click(); + else group.collapsed = true; + await Vue.nextTick(); + const buttons = [...host.querySelectorAll("[data-tab-id]")]; + expect(buttons.filter((button) => button.style.display !== "none")).toHaveLength(standalone ? 3 : 1); + expect(parseFloat(expandedWidth) - parseFloat(getComputedStyle(capsule).width)).toBe(standalone ? 0 : 360); + expect(host.querySelector("[data-group-active='true']") !== null).toBe(!standalone); + if (label) label.click(); + else group.collapsed = false; + await Vue.nextTick(); + expect(getComputedStyle(capsule).width).toBe(expandedWidth); + const currentImages = [...host.querySelectorAll("img")]; + images.forEach((image, index) => expect(currentImages[index]).toBe(image)); + } + } finally { + app.unmount(); + host.remove(); + } + } +); diff --git a/ui/components/Workspace/tabHeader.vue b/ui/components/Workspace/tabHeader.vue index 03c3859ed..7773ceab5 100644 --- a/ui/components/Workspace/tabHeader.vue +++ b/ui/components/Workspace/tabHeader.vue @@ -68,9 +68,8 @@ const renameTabId = ref(""); const renameValue = ref(""); const showShortcutHints = ref(false); -const TAB_MAX_WIDTH = 176; const TAB_GAP = 4; -const groupElements = ref([]); +const groupElements: Ref = ref([]); const groupLabelsWidth = ref(0); function updateGroupLabelsWidth() { groupLabelsWidth.value = groupElements.value.reduce((sum, element) => sum + element.getBoundingClientRect().width, 0); @@ -79,7 +78,7 @@ watchPostEffect(updateGroupLabelsWidth); useResizeObserver(groupElements, () => requestAnimationFrame(updateGroupLabelsWidth)); type TabStripEntry = | { kind: "group"; group: WorkspaceTabGroup; count: number; active: boolean } - | { kind: "tab"; tab: WorkspaceSessionTab; index: number }; + | { kind: "tab"; tab: WorkspaceSessionTab; index: number; hidden: boolean }; const tabStripEntries = computed(() => { const entries: TabStripEntry[] = []; tabs.value.forEach((tab, index) => { @@ -93,14 +92,16 @@ const tabStripEntries = computed(() => { active: members.some((item) => item.id === activeTabId.value) }); } - if (!group?.collapsed) entries.push({ kind: "tab", tab, index }); + // Keep tab icons mounted across collapse/expand so their images are not requested again. + entries.push({ kind: "tab", tab, index, hidden: !!group?.collapsed }); }); return entries; }); const tabStripIdealWidth = computed(() => { - const entries = tabStripEntries.value; - const width = groupLabelsWidth.value + entries.filter((entry) => entry.kind === "tab").length * TAB_MAX_WIDTH; - return `${width + Math.max(0, entries.length - 1) * TAB_GAP}px`; + const entries = tabStripEntries.value.filter((entry) => entry.kind === "group" || !entry.hidden); + const count = entries.filter((entry) => entry.kind === "tab").length; + const spacing = groupLabelsWidth.value + Math.max(0, entries.length - 1) * TAB_GAP; + return `calc(${count} * var(--workspace-session-tab-width) + ${spacing}px)`; }); const { activeTab } = useWorkspaceTabs(); @@ -835,6 +836,7 @@ watch(activeTabId, () => nextTick(scrollActiveTabIntoView)); v-if="entry.kind === 'group'" ref="groupElements" :data-group-id="entry.group.id" + :data-group-collapsed="entry.group.collapsed" :data-group-active="entry.active && entry.group.collapsed" class="workspace-tab-group relative flex h-7 w-max min-w-0 max-w-44 shrink-0 items-center rounded-md" :class="{ @@ -850,7 +852,7 @@ watch(activeTabId, () => nextTick(scrollActiveTabIntoView)); color="neutral" variant="ghost" size="xs" - class="workspace-tab-group-label h-6 min-w-0 flex-1 gap-1 px-2 text-[11px]" + class="workspace-tab-group-label h-[22px] min-w-0 flex-1 gap-1 p-1 text-[11px]" :aria-expanded="!entry.group.collapsed" :aria-label="`${t(entry.group.collapsed ? 'TabMenu.ExpandGroup' : 'TabMenu.CollapseGroup')}: ${groupDisplayTitle(entry.group)}`" :title="groupDisplayTitle(entry.group)" @@ -862,12 +864,13 @@ watch(activeTabId, () => nextTick(scrollActiveTabIntoView));