From 2296d47cc6a04a6db574cce34b5433fb1d2b9350 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 18:48:07 -0800 Subject: [PATCH 01/10] block prompt (for AI), and working on a launcher block --- frontend/app/block/block.tsx | 2 + frontend/app/store/global.ts | 25 ++- frontend/app/view/launcher/launcher.tsx | 156 ++++++++++++++++ frontend/app/view/view-prompt.md | 239 ++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 frontend/app/view/launcher/launcher.tsx create mode 100644 frontend/app/view/view-prompt.md diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index badead269..58e119927 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -9,6 +9,7 @@ import { FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; +import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { VDomModel } from "@/app/view/vdom/vdom-model"; @@ -44,6 +45,7 @@ BlockRegistry.set("cpuplot", SysinfoViewModel); BlockRegistry.set("sysinfo", SysinfoViewModel); BlockRegistry.set("vdom", VDomModel); BlockRegistry.set("help", HelpViewModel); +BlockRegistry.set("launcher", LauncherViewModel); function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { const ctor = BlockRegistry.get(blockView); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 8bfe03f01..4e20a25ee 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -10,7 +10,11 @@ import { newLayoutNode, } from "@/layout/index"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; -import { LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction } from "@/layout/lib/types"; +import { + LayoutTreeReplaceNodeAction, + LayoutTreeSplitHorizontalAction, + LayoutTreeSplitVerticalAction, +} from "@/layout/lib/types"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { deepCompareReturnPrev, getPrefixedSettings, isBlank } from "@/util/util"; @@ -447,6 +451,24 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa return blockId; } +async function replaceBlock(blockId: string, blockDef: BlockDef): Promise { + const tabId = globalStore.get(atoms.staticTabId); + const layoutModel = getLayoutModelForTabById(tabId); + const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); + const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id; + if (targetNodeId == null) { + throw new Error(`targetNodeId not found for blockId: ${blockId}`); + } + const replaceNodeAction: LayoutTreeReplaceNodeAction = { + type: LayoutTreeActionType.ReplaceNode, + targetNodeId: targetNodeId, + newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }), + }; + layoutModel.treeReducer(replaceNodeAction); + return newBlockId; +} + // when file is not found, returns {data: null, fileInfo: null} async function fetchWaveFile( zoneId: string, @@ -761,6 +783,7 @@ export { removeFlashError, removeNotification, removeNotificationById, + replaceBlock, setActiveTab, setNodeFocus, setPlatform, diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx new file mode 100644 index 000000000..cbadd0828 --- /dev/null +++ b/frontend/app/view/launcher/launcher.tsx @@ -0,0 +1,156 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import Logo from "@/app/asset/logo.svg"; // Your SVG logo +import { atoms, replaceBlock } from "@/app/store/global"; +import { isBlank, makeIconClass } from "@/util/util"; +import clsx from "clsx"; +import { atom, useAtomValue } from "jotai"; +import React, { useLayoutEffect, useMemo, useRef, useState } from "react"; + +function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] { + if (!wmap) return []; + const wlist = Object.values(wmap); + wlist.sort((a, b) => (a["display:order"] ?? 0) - (b["display:order"] ?? 0)); + return wlist; +} + +export class LauncherViewModel implements ViewModel { + viewType = "launcher"; + viewIcon = atom("shapes"); + viewName = atom("Widget Launcher"); + viewComponent = LauncherView; +} + +const LauncherView: React.FC> = ({ blockId, model }) => { + const fullConfig = useAtomValue(atoms.fullConfigAtom); + const widgetMap = fullConfig?.widgets || {}; + const widgets = sortByDisplayOrder(widgetMap); + const widgetCount = widgets.length; + + // Container measurement + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + + useLayoutEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver((entries) => { + for (let entry of entries) { + setContainerSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + resizeObserver.observe(containerRef.current); + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Layout constants + const GAP = 16; // gap between grid items (px) + const LABEL_THRESHOLD = 60; // if tile height is below this, hide the label + const MARGIN_BOTTOM = 24; // space below the logo + const MAX_TILE_SIZE = 120; // max widget box size + + // Dynamic logo sizing: 30% of container width, clamped between 100 and 300. + const calculatedLogoWidth = containerSize.width * 0.3; + const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0; + const showLogo = logoWidth >= 100; + + // Available height for the grid (after subtracting logo space) + const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0); + + // Determine optimal grid layout based on container dimensions and widget count. + const gridLayout = useMemo(() => { + if (containerSize.width === 0 || availableHeight <= 0 || widgetCount === 0) { + return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true }; + } + let bestColumns = 1; + let bestTileSize = 0; + let bestTileWidth = 90; + let bestTileHeight = 90; + let showLabel = true; + for (let cols = 1; cols <= widgetCount; cols++) { + const rows = Math.ceil(widgetCount / cols); + const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols; + const tileHeight = (availableHeight - (rows - 1) * GAP) / rows; + const currentTileSize = Math.min(tileWidth, tileHeight); + if (currentTileSize > bestTileSize) { + bestTileSize = currentTileSize; + bestColumns = cols; + bestTileWidth = tileWidth; + bestTileHeight = tileHeight; + showLabel = tileHeight >= LABEL_THRESHOLD; + } + } + return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel }; + }, [containerSize, availableHeight, widgetCount]); + + // Clamp tile sizes so they don't exceed MAX_TILE_SIZE. + const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE); + const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth; + + const handleWidgetSelect = async (widget: WidgetConfigType) => { + try { + await replaceBlock(blockId, widget.blockdef); + } catch (error) { + console.error("Error replacing block:", error); + } + }; + + return ( +
+ {/* Logo wrapped in a div for proper scaling */} + {showLogo && ( +
+ +
+ )} + + {/* Grid of widgets */} +
+ {widgets.map((widget, index) => { + if (widget["display:hidden"]) return null; + return ( +
handleWidgetSelect(widget)} + title={widget.description || widget.label} + className={clsx( + "flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center", + "bg-white/5 hover:bg-white/10", + "text-secondary hover:text-white" + )} + style={{ + width: finalTileWidth, + height: finalTileHeight, + }} + > +
+ +
+ {gridLayout.showLabel && !isBlank(widget.label) && ( +
+ {widget.label} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default LauncherView; diff --git a/frontend/app/view/view-prompt.md b/frontend/app/view/view-prompt.md new file mode 100644 index 000000000..d9175746a --- /dev/null +++ b/frontend/app/view/view-prompt.md @@ -0,0 +1,239 @@ +# Wave Terminal ViewModel Guide + +## Overview + +Wave Terminal uses a modular ViewModel system to define interactive blocks. Each block has a **ViewModel**, which manages its metadata, configuration, and state using **Jotai atoms**. The ViewModel also specifies a **React component (ViewComponent)** that renders the block. + +### Key Concepts + +1. **ViewModel Structure** + + - Implements the `ViewModel` interface. + - Defines: + - `viewType`: Unique block type identifier. + - `viewIcon`, `viewName`, `viewText`: Atoms for UI metadata. + - `preIconButton`, `endIconButtons`: Atoms for action buttons. + - `blockBg`: Atom for background styling. + - `manageConnection`, `noPadding`, `searchAtoms`. + - `viewComponent`: React component rendering the block. + - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`. + +2. **ViewComponent Structure** + + - A **React function component** implementing `ViewComponentProps`. + - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props. + - Retrieves ViewModel state using Jotai atoms. + - Returns JSX for rendering. + +3. **Header Elements (`HeaderElem[]`)** + + - Can include: + - **Icons (`IconButtonDecl`)**: Clickable buttons. + - **Text (`HeaderText`)**: Metadata or status. + - **Inputs (`HeaderInput`)**: Editable fields. + - **Menu Buttons (`MenuButton`)**: Dropdowns. + +4. **Jotai Atoms for State Management** + + - Use `atom`, `PrimitiveAtom`, `WritableAtom` for dynamic properties. + - `splitAtom` for managing lists of atoms. + - Read settings from `globalStore` and override with block metadata. + +5. **Metadata vs. Global Config** + + - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`). + - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files. + - **Cascading Behavior**: + - Blocks first check their **own metadata** for settings. + - If no override exists, they **fall back** to global config. + - Updating a block's setting is done via `SetMetaCommand` (persisted per block). + - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden). + +6. **Useful Helper Functions** + + - To avoid repetitive boilerplate, use these global utilities from `global.ts`: + - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata. + - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides. + - `useSettingsKeyAtom(key)`: Accesses global settings efficiently. + +7. **Styling** + - Use TailWind CSS to style components + - Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg + - Hover background is: bg-hoverbg + - Border color is "border", so use border-border + - Colors are also defined for error, warning, and success (text-error, text-warning, text-su cess) + +## Relevant TypeScript Types + +```typescript +type ViewComponentProps = { + blockId: string; + blockRef: React.RefObject; + contentRef: React.RefObject; + model: T; +}; + +type ViewComponent = React.FC>; + +interface ViewModel { + viewType: string; + viewIcon?: jotai.Atom; + viewName?: jotai.Atom; + viewText?: jotai.Atom; + preIconButton?: jotai.Atom; + endIconButtons?: jotai.Atom; + blockBg?: jotai.Atom; + manageConnection?: jotai.Atom; + noPadding?: jotai.Atom; + searchAtoms?: SearchAtoms; + viewComponent: ViewComponent; + dispose?: () => void; + giveFocus?: () => boolean; + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; +} + +interface IconButtonDecl { + elemtype: "iconbutton"; + icon: string | React.ReactNode; + click?: (e: React.MouseEvent) => void; +} +type HeaderElem = + | IconButtonDecl + | ToggleIconButtonDecl + | HeaderText + | HeaderInput + | HeaderDiv + | HeaderTextButton + | ConnectionButton + | MenuButton; + +type IconButtonCommon = { + icon: string | React.ReactNode; + iconColor?: string; + iconSpin?: boolean; + className?: string; + title?: string; + disabled?: boolean; + noAction?: boolean; +}; + +type IconButtonDecl = IconButtonCommon & { + elemtype: "iconbutton"; + click?: (e: React.MouseEvent) => void; + longClick?: (e: React.MouseEvent) => void; +}; + +type ToggleIconButtonDecl = IconButtonCommon & { + elemtype: "toggleiconbutton"; + active: jotai.WritableAtom; +}; + +type HeaderTextButton = { + elemtype: "textbutton"; + text: string; + className?: string; + title?: string; + onClick?: (e: React.MouseEvent) => void; +}; + +type HeaderText = { + elemtype: "text"; + text: string; + ref?: React.MutableRefObject; + className?: string; + noGrow?: boolean; + onClick?: (e: React.MouseEvent) => void; +}; + +type HeaderInput = { + elemtype: "input"; + value: string; + className?: string; + isDisabled?: boolean; + ref?: React.MutableRefObject; + onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; +}; + +type HeaderDiv = { + elemtype: "div"; + className?: string; + children: HeaderElem[]; + onMouseOver?: (e: React.MouseEvent) => void; + onMouseOut?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent) => void; +}; + +type ConnectionButton = { + elemtype: "connectionbutton"; + icon: string; + text: string; + iconColor: string; + onClick?: (e: React.MouseEvent) => void; + connected: boolean; +}; + +type MenuItem = { + label: string; + icon?: string | React.ReactNode; + subItems?: MenuItem[]; + onClick?: (e: React.MouseEvent) => void; +}; + +type MenuButtonProps = { + items: MenuItem[]; + className?: string; + text: string; + title?: string; + menuPlacement?: Placement; +}; + +type MenuButton = { + elemtype: "menubutton"; +} & MenuButtonProps; +``` + +## Minimal "Hello World" Example + +This example defines a simple ViewModel and ViewComponent for a block that displays "Hello, World!". + +```typescript +import * as jotai from "jotai"; +import React from "react"; + +class HelloWorldModel implements ViewModel { + viewType = "helloworld"; + viewIcon = jotai.atom("smile"); + viewName = jotai.atom("Hello World"); + viewText = jotai.atom("A simple greeting block"); + viewComponent = HelloWorldView; +} + +const HelloWorldView: ViewComponent = ({ model }) => { + return
Hello, World!
; +}; + +export { HelloWorldModel }; + +``` + +## Instructions to AI + +1. Generate a new **ViewModel** class for a block, following the structure above. +2. Generate a corresponding **ViewComponent**. +3. Use **Jotai atoms** to store all dynamic state. +4. Ensure the ViewModel defines **header elements** (`viewText`, `viewIcon`, `endIconButtons`). +5. Export the view model (to be registered in the BlockRegistry) +6. Use existing metadata patterns for config and settings. + +## Other Notes + +- The types you see above don't need to be imported, they are global types (custom.d.ts) + +**Output Format:** + +- TypeScript code defining the **ViewModel**. +- TypeScript code defining the **ViewComponent**. +- Ensure alignment with the patterns in `waveai.tsx`, `preview.tsx`, `sysinfo.tsx`, and `term.tsx`. From 727594b1c1103508b600e8f9dbfe433156e3653d Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 20:24:48 -0800 Subject: [PATCH 02/10] noHeader in viewmodel and add a focus to replacenode --- frontend/app/block/blockframe.tsx | 4 +++- frontend/app/store/global.ts | 1 + frontend/app/view/launcher/launcher.tsx | 1 + frontend/layout/lib/layoutTree.ts | 3 +++ frontend/layout/lib/types.ts | 1 + frontend/types/custom.d.ts | 2 ++ 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 6a9e4185d..7d359993f 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -537,6 +537,8 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const [magnifiedBlockOpacityAtom] = React.useState(() => getSettingsKeyAtom("window:magnifiedblockopacity")); const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); const connBtnRef = React.useRef(); + const noHeader = util.useAtomValueSafe(viewModel?.noHeader); + React.useEffect(() => { if (!manageConnection) { return; @@ -618,7 +620,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { /> )}
- {headerElem} + {noHeader || {headerElem}} {preview ? previewElem : children}
{preview || viewModel == null || !connModalOpen ? null : ( diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 4e20a25ee..0ee5ce87e 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -464,6 +464,7 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise> = ({ blockId, model }) => { diff --git a/frontend/layout/lib/layoutTree.ts b/frontend/layout/lib/layoutTree.ts index e60771acf..1c7e6bba9 100644 --- a/frontend/layout/lib/layoutTree.ts +++ b/frontend/layout/lib/layoutTree.ts @@ -450,6 +450,9 @@ export function replaceNode(layoutState: LayoutTreeState, action: LayoutTreeRepl newNode.size = targetNode.size; parent.children[index] = newNode; } + if (action.focused) { + layoutState.focusedNodeId = newNode.id; + } layoutState.generation++; } diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts index 1c8fa1f1d..d250fa74e 100644 --- a/frontend/layout/lib/types.ts +++ b/frontend/layout/lib/types.ts @@ -195,6 +195,7 @@ export interface LayoutTreeReplaceNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ReplaceNode; targetNodeId: string; newNode: LayoutNode; + focused?: boolean; } // SplitHorizontal: split the current block horizontally. diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d0e8fbc92..70a777761 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -282,6 +282,8 @@ declare global { // Background styling metadata for the block. blockBg?: jotai.Atom; + noHeader?: jotai.Atom; + // Whether the block manages its own connection (e.g., for remote access). manageConnection?: jotai.Atom; From 8c01f8276b7932179554f4c5fb4abb9779ca1ccb Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 20:52:04 -0800 Subject: [PATCH 03/10] new setting to switch the default block --- frontend/app/store/keymodel.ts | 60 +++++++++---------------- frontend/types/gotypes.d.ts | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 3 ++ 6 files changed, 29 insertions(+), 38 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 5a4cf9c58..6aa640147 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -10,6 +10,7 @@ import { getAllBlockComponentModels, getApi, getBlockComponentModel, + getSettingsKeyAtom, globalStore, refocusNode, WOS, @@ -176,7 +177,17 @@ function globalRefocus() { refocusNode(blockId); } -async function handleCmdN() { +function getDefaultNewBlockDef(): BlockDef { + const adnbAtom = getSettingsKeyAtom("app:defaultnewblock"); + const adnb = globalStore.get(adnbAtom) ?? "term"; + if (adnb == "launcher") { + return { + meta: { + view: "launcher", + }, + }; + } + // "term", blank, anything else, fall back to terminal const termBlockDef: BlockDef = { meta: { view: "term", @@ -197,59 +208,32 @@ async function handleCmdN() { termBlockDef.meta.connection = blockData.meta.connection; } } - await createBlock(termBlockDef); + return termBlockDef; +} + +async function handleCmdN() { + const blockDef = getDefaultNewBlockDef(); + await createBlock(blockDef); } async function handleSplitHorizontal() { - // split horizontally - const termBlockDef: BlockDef = { - meta: { - view: "term", - controller: "shell", - }, - }; const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); - const blockData = globalStore.get(blockAtom); - if (blockData?.meta?.view == "term") { - if (blockData?.meta?.["cmd:cwd"] != null) { - termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; - } - } - if (blockData?.meta?.connection != null) { - termBlockDef.meta.connection = blockData.meta.connection; - } - await createBlockSplitHorizontally(termBlockDef, focusedNode.data.blockId, "after"); + const blockDef = getDefaultNewBlockDef(); + await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, "after"); } async function handleSplitVertical() { - // split horizontally - const termBlockDef: BlockDef = { - meta: { - view: "term", - controller: "shell", - }, - }; const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); - const blockData = globalStore.get(blockAtom); - if (blockData?.meta?.view == "term") { - if (blockData?.meta?.["cmd:cwd"] != null) { - termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; - } - } - if (blockData?.meta?.connection != null) { - termBlockDef.meta.connection = blockData.meta.connection; - } - await createBlockSplitVertically(termBlockDef, focusedNode.data.blockId, "after"); + const blockDef = getDefaultNewBlockDef(); + await createBlockSplitVertically(blockDef, focusedNode.data.blockId, "after"); } let lastHandledEvent: KeyboardEvent | null = null; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d7270ae90..b6e1ca2ba 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -681,6 +681,7 @@ declare global { "app:*"?: boolean; "app:globalhotkey"?: string; "app:dismissarchitecturewarning"?: boolean; + "app:defaultnewblock"?: string; "ai:*"?: boolean; "ai:preset"?: string; "ai:apitype"?: string; diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 3da7e6fd2..1cfbc1356 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -3,6 +3,7 @@ "ai:model": "gpt-4o-mini", "ai:maxtokens": 2048, "ai:timeoutms": 60000, + "app:defaultnewblock": "term", "autoupdate:enabled": true, "autoupdate:installonquit": true, "autoupdate:intervalms": 3600000, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 4e95a3373..0185b3bc2 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -9,6 +9,7 @@ const ( ConfigKey_AppClear = "app:*" ConfigKey_AppGlobalHotkey = "app:globalhotkey" ConfigKey_AppDismissArchitectureWarning = "app:dismissarchitecturewarning" + ConfigKey_AppDefaultNewBlock = "app:defaultnewblock" ConfigKey_AiClear = "ai:*" ConfigKey_AiPreset = "ai:preset" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 46d27a13c..ff4aae89a 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -52,6 +52,7 @@ type SettingsType struct { AppClear bool `json:"app:*,omitempty"` AppGlobalHotkey string `json:"app:globalhotkey,omitempty"` AppDismissArchitectureWarning bool `json:"app:dismissarchitecturewarning,omitempty"` + AppDefaultNewBlock string `json:"app:defaultnewblock,omitempty"` AiSettingsType diff --git a/schema/settings.json b/schema/settings.json index abe5caac4..395974b57 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -14,6 +14,9 @@ "app:dismissarchitecturewarning": { "type": "boolean" }, + "app:defaultnewblock": { + "type": "string" + }, "ai:*": { "type": "boolean" }, From ad80b96c76994fb41b7579afa95a32f69d98bb08 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 21:38:00 -0800 Subject: [PATCH 04/10] searchin launcher --- frontend/app/view/launcher/launcher.tsx | 195 ++++++++++++++++++------ 1 file changed, 145 insertions(+), 50 deletions(-) diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index 0bca0caec..18e2bf644 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -1,12 +1,12 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import Logo from "@/app/asset/logo.svg"; // Your SVG logo +import logoUrl from "@/app/asset/logo.svg?url"; import { atoms, replaceBlock } from "@/app/store/global"; import { isBlank, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { atom, useAtomValue } from "jotai"; -import React, { useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] { if (!wmap) return []; @@ -21,13 +21,31 @@ export class LauncherViewModel implements ViewModel { viewName = atom("Widget Launcher"); viewComponent = LauncherView; noHeader = atom(true); + inputRef = { current: null } as React.RefObject; + + giveFocus(): boolean { + if (this.inputRef.current) { + this.inputRef.current.focus(); + return true; + } + return false; + } } const LauncherView: React.FC> = ({ blockId, model }) => { const fullConfig = useAtomValue(atoms.fullConfigAtom); const widgetMap = fullConfig?.widgets || {}; const widgets = sortByDisplayOrder(widgetMap); - const widgetCount = widgets.length; + + // Search and selection state + const [searchTerm, setSearchTerm] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Filter widgets based on search term + const filteredWidgets = widgets.filter( + (widget) => + !widget["display:hidden"] && (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase())) + ); // Container measurement const containerRef = useRef(null); @@ -50,22 +68,19 @@ const LauncherView: React.FC> = ({ blockId }, []); // Layout constants - const GAP = 16; // gap between grid items (px) - const LABEL_THRESHOLD = 60; // if tile height is below this, hide the label - const MARGIN_BOTTOM = 24; // space below the logo - const MAX_TILE_SIZE = 120; // max widget box size + const GAP = 16; + const LABEL_THRESHOLD = 60; + const MARGIN_BOTTOM = 24; + const MAX_TILE_SIZE = 120; - // Dynamic logo sizing: 30% of container width, clamped between 100 and 300. const calculatedLogoWidth = containerSize.width * 0.3; const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0; const showLogo = logoWidth >= 100; - - // Available height for the grid (after subtracting logo space) const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0); - // Determine optimal grid layout based on container dimensions and widget count. - const gridLayout = useMemo(() => { - if (containerSize.width === 0 || availableHeight <= 0 || widgetCount === 0) { + // Determine optimal grid layout + const gridLayout = React.useMemo(() => { + if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) { return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true }; } let bestColumns = 1; @@ -73,8 +88,8 @@ const LauncherView: React.FC> = ({ blockId let bestTileWidth = 90; let bestTileHeight = 90; let showLabel = true; - for (let cols = 1; cols <= widgetCount; cols++) { - const rows = Math.ceil(widgetCount / cols); + for (let cols = 1; cols <= filteredWidgets.length; cols++) { + const rows = Math.ceil(filteredWidgets.length / cols); const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols; const tileHeight = (availableHeight - (rows - 1) * GAP) / rows; const currentTileSize = Math.min(tileWidth, tileHeight); @@ -87,12 +102,12 @@ const LauncherView: React.FC> = ({ blockId } } return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel }; - }, [containerSize, availableHeight, widgetCount]); + }, [containerSize, availableHeight, filteredWidgets.length]); - // Clamp tile sizes so they don't exceed MAX_TILE_SIZE. const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE); const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth; + // Handle widget selection and launch const handleWidgetSelect = async (widget: WidgetConfigType) => { try { await replaceBlock(blockId, widget.blockdef); @@ -101,12 +116,81 @@ const LauncherView: React.FC> = ({ blockId } }; + // Keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const rows = Math.ceil(filteredWidgets.length / gridLayout.columns); + const currentRow = Math.floor(selectedIndex / gridLayout.columns); + const currentCol = selectedIndex % gridLayout.columns; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (currentRow > 0) { + const newIndex = selectedIndex - gridLayout.columns; + if (newIndex >= 0) setSelectedIndex(newIndex); + } + break; + case "ArrowDown": + e.preventDefault(); + if (currentRow < rows - 1) { + const newIndex = selectedIndex + gridLayout.columns; + if (newIndex < filteredWidgets.length) setSelectedIndex(newIndex); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (currentCol > 0) setSelectedIndex(selectedIndex - 1); + break; + case "ArrowRight": + e.preventDefault(); + if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) { + setSelectedIndex(selectedIndex + 1); + } + break; + case "Enter": + e.preventDefault(); + if (filteredWidgets[selectedIndex]) { + handleWidgetSelect(filteredWidgets[selectedIndex]); + } + break; + case "Escape": + e.preventDefault(); + setSearchTerm(""); + setSelectedIndex(0); + break; + } + }, + [selectedIndex, gridLayout.columns, filteredWidgets.length, handleWidgetSelect] + ); + + // Set up keyboard listeners + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + // Reset selection when search term changes + useEffect(() => { + setSelectedIndex(0); + }, [searchTerm]); + return (
- {/* Logo wrapped in a div for proper scaling */} + {/* Hidden input for search */} + setSearchTerm(e.target.value)} + className="sr-only" + aria-label="Search widgets" + /> + + {/* Logo */} {showLogo && (
- + Logo
)} @@ -117,38 +201,49 @@ const LauncherView: React.FC> = ({ blockId gridTemplateColumns: `repeat(${gridLayout.columns}, ${finalTileWidth}px)`, }} > - {widgets.map((widget, index) => { - if (widget["display:hidden"]) return null; - return ( -
handleWidgetSelect(widget)} - title={widget.description || widget.label} - className={clsx( - "flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center", - "bg-white/5 hover:bg-white/10", - "text-secondary hover:text-white" - )} - style={{ - width: finalTileWidth, - height: finalTileHeight, - }} - > -
- -
- {gridLayout.showLabel && !isBlank(widget.label) && ( -
- {widget.label} -
- )} + {filteredWidgets.map((widget, index) => ( +
handleWidgetSelect(widget)} + title={widget.description || widget.label} + className={clsx( + "flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center", + "transition-colors duration-150", + index === selectedIndex + ? "bg-white/20 text-white" + : "bg-white/5 hover:bg-white/10 text-secondary hover:text-white" + )} + style={{ + width: finalTileWidth, + height: finalTileHeight, + }} + > +
+
- ); - })} + {gridLayout.showLabel && !isBlank(widget.label) && ( +
+ {widget.label} +
+ )} +
+ ))} +
+ + {/* Search instructions */} +
+ {filteredWidgets.length === 0 ? ( + No widgets found. Press Escape to clear search. + ) : ( + + {searchTerm == "" ? "Type to Filter" : "Searching " + '"' + searchTerm + '"'}, Enter to Launch, + {searchTerm == "" ? "Arrow Keys to Navigate" : null} + + )}
); From 024b980dfaad4d0f49791e2a1e628281debee2ba Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 22:15:27 -0800 Subject: [PATCH 05/10] keydown and atoms moved to model --- frontend/app/view/launcher/launcher.tsx | 177 +++++++++++++----------- 1 file changed, 96 insertions(+), 81 deletions(-) diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index 18e2bf644..1694b8928 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import logoUrl from "@/app/asset/logo.svg?url"; -import { atoms, replaceBlock } from "@/app/store/global"; +import { atoms, globalStore, replaceBlock } from "@/app/store/global"; +import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isBlank, makeIconClass } from "@/util/util"; import clsx from "clsx"; -import { atom, useAtomValue } from "jotai"; -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { atom, useAtom, useAtomValue } from "jotai"; +import React, { useEffect, useLayoutEffect, useRef } from "react"; function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] { if (!wmap) return []; @@ -15,13 +16,34 @@ function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | u return wlist; } +type GridLayoutType = { columns: number; tileWidth: number; tileHeight: number; showLabel: boolean }; + export class LauncherViewModel implements ViewModel { + blockId: string; viewType = "launcher"; viewIcon = atom("shapes"); viewName = atom("Widget Launcher"); viewComponent = LauncherView; noHeader = atom(true); inputRef = { current: null } as React.RefObject; + searchTerm = atom(""); + selectedIndex = atom(0); + containerSize = atom({ width: 0, height: 0 }); + gridLayout: GridLayoutType = null; + + constructor(blockId: string) { + this.blockId = blockId; + } + + filteredWidgetsAtom = atom((get) => { + const searchTerm = get(this.searchTerm); + const widgets = sortByDisplayOrder(get(atoms.fullConfigAtom)?.widgets || {}); + return widgets.filter( + (widget) => + !widget["display:hidden"] && + (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }); giveFocus(): boolean { if (this.inputRef.current) { @@ -30,26 +52,80 @@ export class LauncherViewModel implements ViewModel { } return false; } + + keyDownHandler(e: WaveKeyboardEvent): boolean { + if (this.gridLayout == null) { + return; + } + const gridLayout = this.gridLayout; + const filteredWidgets = globalStore.get(this.filteredWidgetsAtom); + const selectedIndex = globalStore.get(this.selectedIndex); + const rows = Math.ceil(filteredWidgets.length / gridLayout.columns); + const currentRow = Math.floor(selectedIndex / gridLayout.columns); + const currentCol = selectedIndex % gridLayout.columns; + console.log("keydown", e); + if (checkKeyPressed(e, "ArrowUp")) { + if (currentRow > 0) { + const newIndex = selectedIndex - gridLayout.columns; + if (newIndex >= 0) { + globalStore.set(this.selectedIndex, newIndex); + } + } + return true; + } + if (checkKeyPressed(e, "ArrowDown")) { + if (currentRow < rows - 1) { + const newIndex = selectedIndex + gridLayout.columns; + if (newIndex < filteredWidgets.length) { + globalStore.set(this.selectedIndex, newIndex); + } + } + return true; + } + if (checkKeyPressed(e, "ArrowLeft")) { + if (currentCol > 0) { + globalStore.set(this.selectedIndex, selectedIndex - 1); + } + return true; + } + if (checkKeyPressed(e, "ArrowRight")) { + if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) { + globalStore.set(this.selectedIndex, selectedIndex + 1); + } + return true; + } + if (checkKeyPressed(e, "Enter")) { + if (filteredWidgets[selectedIndex]) { + this.handleWidgetSelect(filteredWidgets[selectedIndex]); + } + return true; + } + if (checkKeyPressed(e, "Escape")) { + globalStore.set(this.searchTerm, ""); + globalStore.set(this.selectedIndex, 0); + return true; + } + return false; + } + + async handleWidgetSelect(widget: WidgetConfigType) { + try { + await replaceBlock(this.blockId, widget.blockdef); + } catch (error) { + console.error("Error replacing block:", error); + } + } } const LauncherView: React.FC> = ({ blockId, model }) => { - const fullConfig = useAtomValue(atoms.fullConfigAtom); - const widgetMap = fullConfig?.widgets || {}; - const widgets = sortByDisplayOrder(widgetMap); - // Search and selection state - const [searchTerm, setSearchTerm] = useState(""); - const [selectedIndex, setSelectedIndex] = useState(0); - - // Filter widgets based on search term - const filteredWidgets = widgets.filter( - (widget) => - !widget["display:hidden"] && (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase())) - ); + const [searchTerm, setSearchTerm] = useAtom(model.searchTerm); + const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex); + const filteredWidgets = useAtomValue(model.filteredWidgetsAtom); // Container measurement const containerRef = useRef(null); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const [containerSize, setContainerSize] = useAtom(model.containerSize); useLayoutEffect(() => { if (!containerRef.current) return; @@ -79,7 +155,7 @@ const LauncherView: React.FC> = ({ blockId const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0); // Determine optimal grid layout - const gridLayout = React.useMemo(() => { + const gridLayout: GridLayoutType = React.useMemo(() => { if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) { return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true }; } @@ -103,73 +179,11 @@ const LauncherView: React.FC> = ({ blockId } return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel }; }, [containerSize, availableHeight, filteredWidgets.length]); + model.gridLayout = gridLayout; const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE); const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth; - // Handle widget selection and launch - const handleWidgetSelect = async (widget: WidgetConfigType) => { - try { - await replaceBlock(blockId, widget.blockdef); - } catch (error) { - console.error("Error replacing block:", error); - } - }; - - // Keyboard navigation - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const rows = Math.ceil(filteredWidgets.length / gridLayout.columns); - const currentRow = Math.floor(selectedIndex / gridLayout.columns); - const currentCol = selectedIndex % gridLayout.columns; - - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (currentRow > 0) { - const newIndex = selectedIndex - gridLayout.columns; - if (newIndex >= 0) setSelectedIndex(newIndex); - } - break; - case "ArrowDown": - e.preventDefault(); - if (currentRow < rows - 1) { - const newIndex = selectedIndex + gridLayout.columns; - if (newIndex < filteredWidgets.length) setSelectedIndex(newIndex); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (currentCol > 0) setSelectedIndex(selectedIndex - 1); - break; - case "ArrowRight": - e.preventDefault(); - if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) { - setSelectedIndex(selectedIndex + 1); - } - break; - case "Enter": - e.preventDefault(); - if (filteredWidgets[selectedIndex]) { - handleWidgetSelect(filteredWidgets[selectedIndex]); - } - break; - case "Escape": - e.preventDefault(); - setSearchTerm(""); - setSelectedIndex(0); - break; - } - }, - [selectedIndex, gridLayout.columns, filteredWidgets.length, handleWidgetSelect] - ); - - // Set up keyboard listeners - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); - // Reset selection when search term changes useEffect(() => { setSelectedIndex(0); @@ -182,6 +196,7 @@ const LauncherView: React.FC> = ({ blockId ref={model.inputRef} type="text" value={searchTerm} + onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))} onChange={(e) => setSearchTerm(e.target.value)} className="sr-only" aria-label="Search widgets" @@ -204,7 +219,7 @@ const LauncherView: React.FC> = ({ blockId {filteredWidgets.map((widget, index) => (
handleWidgetSelect(widget)} + onClick={() => model.handleWidgetSelect(widget)} title={widget.description || widget.label} className={clsx( "flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center", From 9d82eb9202b146907fa8cb8ddcfde5c6982194a9 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 22:33:18 -0800 Subject: [PATCH 06/10] add more docs --- docs/docs/config.mdx | 85 +++++++++++++++++++++++++++++++++++++-- docs/docs/keybindings.mdx | 41 ++++++++++--------- 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 6648d1d92..a0b381cff 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -27,6 +27,7 @@ wsh editconfig | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | app:globalhotkey | string | A systemwide keybinding to open your most recent wave window. This is a set of key names separated by `:`. For more info, see [Customizable Systemwide Global Hotkey](#customizable-systemwide-global-hotkey) | | app:dismissarchitecturewarning | bool | Disable warnings on app start when you are using a non-native architecture for Wave. For more info, see [Why does Wave warn me about ARM64 translation when it launches?](./faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches). | +| app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | @@ -91,6 +92,7 @@ For reference, this is the current default configuration (v0.10.4): "ai:model": "gpt-4o-mini", "ai:maxtokens": 2048, "ai:timeoutms": 60000, + "app:defaultnewblock": "term", "autoupdate:enabled": true, "autoupdate:installonquit": true, "autoupdate:intervalms": 3600000, @@ -121,7 +123,82 @@ files as well: `termthemes.json`, `presets.json`, and `widgets.json`. ::: -### Terminal Theming +## WebBookmarks Configuration + +WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention you should start your ids with "bookmark@". + +### Bookmark Structure + +Each bookmark follows this structure (only `url` is required): + +```json +{ + "url": "https://example.com", + "title": "Example Site", + "iconurl": "https://example.com/custom-icon.png", + "display:order": 1 +} +``` + +### Fields + +| Field | Type | Description | +| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | +| url | string | **Required.** The URL of the bookmark. | +| title | string | **Optional.** A display title for the bookmark. | +| icon | string | **Optional, rarely used.** Overrides the default favicon with an icon name. | +| iconcolor | string | **Optional, rarely used.** Sets a custom color for the specified icon. | +| iconurl | string | **Optional.** Provides a custom icon URL, useful if the favicon is incorrect (e.g., for dark mode compatibility). | +| display:order | float64 | **Optional.** Defines the order in which bookmarks appear. | + +### Example `bookmarks.json` + +```json +{ + "bookmark@google": { + "url": "https://www.google.com", + "title": "Google" + }, + "bookmark@claude": { + "url": "https://claude.ai", + "title": "Claude AI" + }, + "bookmark@wave": { + "url": "https://waveterm.dev", + "title": "Wave Terminal", + "display:order": -1 + }, + "bookmark@wave-github": { + "url": "https://github.com/wavetermdev/waveterm", + "title": "Wave Github", + "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" + }, + "bookmark@chatgpt": { + "url": "https://chatgpt.com", + "iconurl": "https://cdn.oaistatic.com/assets/favicon-miwirzcw.ico" + }, + "bookmark@wave-pulls": { + "url": "https://github.com/wavetermdev/waveterm/pulls", + "title": "Wave Pull Requests", + "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" + } +} +``` + +### Behavior + +- If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon. +- Bookmarks are sorted based on `display:order` (if provided), otherwise they follow default order. +- `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon. + +### Use Cases + +- Organizing frequently used links. +- Overriding incorrect favicons for better visibility in different themes (e.g., dark mode). + +Ensure that `bookmarks.json` is structured correctly for proper rendering. + +## Terminal Theming User-defined terminal themes are located in `~/.config/waveterm/termthemes.json`. @@ -203,17 +280,17 @@ wsh editconfig termthemes.json | cursorAccent | CSS color | | | color for cursor | | selectionBackground | CSS color | | | background color for selected text | -### Customizable Systemwide Global Hotkey +## Customizable Systemwide Global Hotkey Wave allows settings a custom global hotkey to open your most recent window from anywhere in your computer. This has the name `"app:globalhotkey"` in the `settings.json` file and takes the form of a series of key names separated by the `:` character. -#### Examples +### Examples As a practical example, suppose you want a value of `F5` as your global hotkey. Then you can simply set the value of `"app:globalhotkey"` to `"F5"` and reboot Wave to make that your global hotkey. As a less practical example, suppose you use the combination of the keys `Ctrl`, `Option`, and `e`. Then the value for this keybinding would be `"Ctrl:Option:e"`. -#### Allowed Key Names +### Allowed Key Names We support the following key names: diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index b73470d26..ee3fac107 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -20,25 +20,27 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L
-| Key | Function | -| ---------------------------- | --------------------------------------------------------------------------------- | -| | Open a new tab | -| | Open a new terminal block (defaults to the same connection and working directory) | -| | Open a new window | -| | Close the current block | -| | Close the current tab | -| | Magnify / Un-Magnify the current block | -| | Open the "connection" switcher | -| | Refocus the current block (useful if the block has lost input focus) | -| | Show block numbers | -| | Switch to block number | -| | Move left, right, up, down between blocks | -| | Switch to tab number | -| | Switch tab left | -| | Switch tab right | -| | Switch to workspace number | -| | Refresh the UI | -| | Toggle terminal multi-input mode | +| Key | Function | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| | Open a new tab | +| | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | +| | Split horizontally, open a new block to the right | +| | Split vertically, open a new block below | +| | Open a new window | +| | Close the current block | +| | Close the current tab | +| | Magnify / Un-Magnify the current block | +| | Open the "connection" switcher | +| | Refocus the current block (useful if the block has lost input focus) | +| | Show block numbers | +| | Switch to block number | +| | Move left, right, up, down between blocks | +| | Switch to tab number | +| | Switch tab left | +| | Switch tab right | +| | Switch to workspace number | +| | Refresh the UI | +| | Toggle terminal multi-input mode | ## File Preview Keybindings @@ -66,6 +68,7 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L | | Back | | | Forward | | | Find in webpage | +| | Open a bookmark | ## WaveAI Keybindings From d9c250519c175299a6519f5a6fa07c488b7a1233 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 22:36:10 -0800 Subject: [PATCH 07/10] minor updates --- frontend/app/view/launcher/launcher.tsx | 1 - frontend/app/view/view-prompt.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index 1694b8928..ce412809d 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -63,7 +63,6 @@ export class LauncherViewModel implements ViewModel { const rows = Math.ceil(filteredWidgets.length / gridLayout.columns); const currentRow = Math.floor(selectedIndex / gridLayout.columns); const currentCol = selectedIndex % gridLayout.columns; - console.log("keydown", e); if (checkKeyPressed(e, "ArrowUp")) { if (currentRow > 0) { const newIndex = selectedIndex - gridLayout.columns; diff --git a/frontend/app/view/view-prompt.md b/frontend/app/view/view-prompt.md index d9175746a..4a7f2ad5a 100644 --- a/frontend/app/view/view-prompt.md +++ b/frontend/app/view/view-prompt.md @@ -61,7 +61,7 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg - Hover background is: bg-hoverbg - Border color is "border", so use border-border - - Colors are also defined for error, warning, and success (text-error, text-warning, text-su cess) + - Colors are also defined for error, warning, and success (text-error, text-warning, text-sucess) ## Relevant TypeScript Types From 00445374fbec774155546e78f10299fb6031d189 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Feb 2025 22:40:45 -0800 Subject: [PATCH 08/10] docs updates --- docs/docs/config.mdx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index a0b381cff..9a211c3cc 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -4,6 +4,14 @@ id: "config" title: "Configuration" --- +import { Kbd } from "@site/src/components/kbd.tsx"; +import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext.tsx"; + + + + +
+ Wave's configuration files are located at `~/.config/waveterm/`. The main configuration file is `settings.json` (`~/.config/waveterm/settings.json`). @@ -125,7 +133,7 @@ files as well: `termthemes.json`, `presets.json`, and `widgets.json`. ## WebBookmarks Configuration -WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention you should start your ids with "bookmark@". +WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention, you should start your ids with "bookmark@". In the web widget, you can pull up your bookmarks using ### Bookmark Structure @@ -188,15 +196,9 @@ Each bookmark follows this structure (only `url` is required): ### Behavior - If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon. -- Bookmarks are sorted based on `display:order` (if provided), otherwise they follow default order. +- Bookmarks are sorted based on `display:order` (if provided), otherwise by id. - `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon. - -### Use Cases - -- Organizing frequently used links. -- Overriding incorrect favicons for better visibility in different themes (e.g., dark mode). - -Ensure that `bookmarks.json` is structured correctly for proper rendering. +- favicons are refreshed every 24-hours ## Terminal Theming @@ -328,3 +330,5 @@ We support the following key names: - The numpad minus/subtract represented by `Subtract` - The numpad star/multiply represented by `Multiply` - The numpad slash/divide represented by `Divide` + +
From 09dff23dbc7e0d0c6da3d62e37d3f15b02bd9dfe Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Feb 2025 09:28:10 -0800 Subject: [PATCH 09/10] check for size 0 --- frontend/app/view/launcher/launcher.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index ce412809d..b0c311f94 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -64,6 +64,9 @@ export class LauncherViewModel implements ViewModel { const currentRow = Math.floor(selectedIndex / gridLayout.columns); const currentCol = selectedIndex % gridLayout.columns; if (checkKeyPressed(e, "ArrowUp")) { + if (filteredWidgets.length == 0) { + return true; + } if (currentRow > 0) { const newIndex = selectedIndex - gridLayout.columns; if (newIndex >= 0) { @@ -73,6 +76,9 @@ export class LauncherViewModel implements ViewModel { return true; } if (checkKeyPressed(e, "ArrowDown")) { + if (filteredWidgets.length == 0) { + return true; + } if (currentRow < rows - 1) { const newIndex = selectedIndex + gridLayout.columns; if (newIndex < filteredWidgets.length) { @@ -82,18 +88,27 @@ export class LauncherViewModel implements ViewModel { return true; } if (checkKeyPressed(e, "ArrowLeft")) { + if (filteredWidgets.length == 0) { + return true; + } if (currentCol > 0) { globalStore.set(this.selectedIndex, selectedIndex - 1); } return true; } if (checkKeyPressed(e, "ArrowRight")) { + if (filteredWidgets.length == 0) { + return true; + } if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) { globalStore.set(this.selectedIndex, selectedIndex + 1); } return true; } if (checkKeyPressed(e, "Enter")) { + if (filteredWidgets.length == 0) { + return true; + } if (filteredWidgets[selectedIndex]) { this.handleWidgetSelect(filteredWidgets[selectedIndex]); } From d37e89707e76549b737dbd50bd87cfcb29e412c9 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Feb 2025 09:44:07 -0800 Subject: [PATCH 10/10] removing sizes in new layout funcs --- frontend/layout/lib/layoutTree.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/layout/lib/layoutTree.ts b/frontend/layout/lib/layoutTree.ts index 1c7e6bba9..74fcad5f3 100644 --- a/frontend/layout/lib/layoutTree.ts +++ b/frontend/layout/lib/layoutTree.ts @@ -476,8 +476,6 @@ export function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTree const insertIndex = position === "before" ? index : index + 1; // Directly splice in the new node instead of calling addChildAt (which may flatten nodes) parent.children.splice(insertIndex, 0, newNode); - // Rebalance sizes equally (or use your own logic) - parent.children.forEach((child) => (child.size = 1)); } else { // Otherwise, if no parent or parent's flexDirection is not Row, we need to wrap // Create a new group node with horizontal layout. @@ -485,7 +483,6 @@ export function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTree const groupNode = newLayoutNode(FlexDirection.Row, targetNode.size, [targetNode], undefined); // Now decide the ordering based on the "position" groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode]; - groupNode.children.forEach((child) => (child.size = 1)); if (parent) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { @@ -523,13 +520,11 @@ export function splitVertical(layoutState: LayoutTreeState, action: LayoutTreeSp const insertIndex = position === "before" ? index : index + 1; // For vertical splits in an already vertical parent, splice directly. parent.children.splice(insertIndex, 0, newNode); - parent.children.forEach((child) => (child.size = 1)); } else { // Wrap target node in a new vertical group. // Create group node with an initial children array so that validation passes. const groupNode = newLayoutNode(FlexDirection.Column, targetNode.size, [targetNode], undefined); groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode]; - groupNode.children.forEach((child) => (child.size = 1)); if (parent) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) {