diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 05389e99ef..d0bc4798e6 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -84,6 +84,7 @@ wsh editconfig | editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | preview:defaultsort | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | +| notes:path | string | path to the notes markdown file (defaults to `~/notes.md`) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | | web:openlinksinternally | bool | set to false to open web links in external browser | diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..4ca0fd7b1e 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -16,6 +16,7 @@ import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import { blockViewToIcon, blockViewToName } from "./blockutil"; import { HelpViewModel } from "@/view/helpview/helpview"; +import { NotesViewModel } from "@/app/view/notes/notes-model"; import { TermViewModel } from "@/view/term/term-model"; import { WaveAiModel } from "@/view/waveai/waveai"; import { WebViewModel } from "@/view/webview/webview"; @@ -35,6 +36,7 @@ BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); BlockRegistry.set("processviewer", ProcessViewerViewModel); +BlockRegistry.set("notes", NotesViewModel); function makeDefaultViewModel(viewType: string): ViewModel { const viewModel: ViewModel = { diff --git a/frontend/app/modals/alertmodal.tsx b/frontend/app/modals/alertmodal.tsx new file mode 100644 index 0000000000..4f5c0b0d21 --- /dev/null +++ b/frontend/app/modals/alertmodal.tsx @@ -0,0 +1,110 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { modalsModel } from "@/app/store/modalmodel"; +import { makeIconClass } from "@/util/util"; +import { ReactNode, useEffect } from "react"; +import ReactDOM from "react-dom"; + +interface AlertModalProps { + children: ReactNode; + title?: string; + icon?: string; + iconClassName?: string; + okLabel?: string; + onClose?: () => void; +} + +interface AlertOptions { + title?: string; + message: ReactNode; + icon?: string; + iconClassName?: string; + okLabel?: string; +} + +function showAlert(opts: AlertOptions) { + modalsModel.pushModal("AlertModal", { + children: opts.message, + title: opts.title ?? "Alert", + icon: opts.icon, + iconClassName: opts.iconClassName, + okLabel: opts.okLabel, + }); +} + +function showErrorAlert(message: ReactNode, title?: string) { + showAlert({ title: title ?? "Error", message, icon: "circle-exclamation", iconClassName: "text-error" }); +} + +const AlertModal = ({ children, title = "Alert", icon = "circle-info", iconClassName, okLabel = "Ok", onClose }: AlertModalProps) => { + function close() { + if (onClose) { + onClose(); + } else { + modalsModel.popModal(); + } + } + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" || e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + } + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, []); + + const iconClass = makeIconClass(icon, false); + + return ReactDOM.createPortal( +
+
+
+
+ + {title} + +
+
{children}
+
+ +
+
+
, + document.getElementById("main") + ); +}; + +AlertModal.displayName = "AlertModal"; + +export { AlertModal, showAlert, showErrorAlert }; +export type { AlertOptions }; diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 88d19e732c..da8f1495f1 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { MessageModal } from "@/app/modals/messagemodal"; +import { AlertModal } from "@/app/modals/alertmodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch"; @@ -16,7 +16,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [UpgradeOnboardingPatch.displayName || "UpgradeOnboardingPatch"]: UpgradeOnboardingPatch, [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, - [MessageModal.displayName || "MessageModal"]: MessageModal, + [AlertModal.displayName || "AlertModal"]: AlertModal, [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..02a674d74a 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -444,6 +444,12 @@ export class RpcApiType { return client.wshRpcCall("getmeta", data, opts); } + // command "getnote" [call] + GetNoteCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getnote", null, opts); + return client.wshRpcCall("getnote", null, opts); + } + // command "getrtinfo" [call] GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getrtinfo", data, opts); @@ -1050,6 +1056,12 @@ export class RpcApiType { return client.wshRpcCall("writeappsecretbindings", data, opts); } + // command "writenote" [call] + WriteNoteCommand(client: WshClient, data: CommandWriteNoteData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writenote", data, opts); + return client.wshRpcCall("writenote", data, opts); + } + // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writetempfile", data, opts); diff --git a/frontend/app/view/notes/notes-model.ts b/frontend/app/view/notes/notes-model.ts new file mode 100644 index 0000000000..dbc6fdf5c5 --- /dev/null +++ b/frontend/app/view/notes/notes-model.ts @@ -0,0 +1,344 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { showErrorAlert } from "@/app/modals/alertmodal"; +import { ClientModel } from "@/app/store/client-model"; +import { globalStore } from "@/app/store/jotaiStore"; +import * as WOS from "@/app/store/wos"; +import { waveEventSubscribeSingle } from "@/app/store/wps"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { NotesView } from "@/app/view/notes/notes"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import * as jotai from "jotai"; +import type * as MonacoTypes from "monaco-editor"; +import React from "react"; +import { debounce } from "throttle-debounce"; + +type NotesEnv = WaveEnvSubset<{ + rpc: { + GetNoteCommand: WaveEnv["rpc"]["GetNoteCommand"]; + WriteNoteCommand: WaveEnv["rpc"]["WriteNoteCommand"]; + GetRTInfoCommand: WaveEnv["rpc"]["GetRTInfoCommand"]; + SetRTInfoCommand: WaveEnv["rpc"]["SetRTInfoCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + }; + getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; +}>; + +type SaveStatus = "idle" | "dirty" | "saved" | "synced" | "error"; + +const SavedDisplayMs = 3000; + +export class NotesViewModel implements ViewModel { + viewType = "notes"; + blockId: string; + env: NotesEnv; + nodeModel: BlockNodeModel; + + viewIcon = jotai.atom("note-sticky"); + viewName = jotai.atom("Notes"); + noPadding = jotai.atom(true); + + contentAtom: jotai.PrimitiveAtom; + filePathAtom: jotai.PrimitiveAtom; + loadErrorAtom: jotai.PrimitiveAtom; + loadedAtom: jotai.PrimitiveAtom; + saveStatusAtom: jotai.PrimitiveAtom; + saveErrorAtom: jotai.PrimitiveAtom; + readOnlyAtom: jotai.PrimitiveAtom; + + viewText!: jotai.Atom; + wordWrapAtom!: jotai.Atom; + + myOref: string; + editorRef: React.RefObject = { current: null }; + savedClearTimer: ReturnType = null; + unsubscribeNotes: () => void = null; + pendingContent: string = null; + isApplyingRemoteEdit = false; + + debouncedSave = debounce(1000, (text: string) => { + this.saveContent(text); + }); + + debouncedSaveCursorPos = debounce(500, (pos: number) => { + this.env.rpc + .SetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("client", ClientModel.getInstance().clientId), + data: { "notes:cursorpos": pos }, + }) + .catch(() => {}); + }); + + constructor({ blockId, nodeModel, waveEnv }: ViewModelInitType) { + this.blockId = blockId; + this.nodeModel = nodeModel; + this.env = waveEnv as NotesEnv; + this.myOref = WOS.makeORef("block", blockId); + this.contentAtom = jotai.atom(""); + this.filePathAtom = jotai.atom(""); + this.loadErrorAtom = jotai.atom(""); + this.loadedAtom = jotai.atom(false); + this.saveStatusAtom = jotai.atom("idle"); + this.saveErrorAtom = jotai.atom(""); + this.readOnlyAtom = jotai.atom(false); + + this.viewText = jotai.atom((get) => { + const status = get(this.saveStatusAtom); + const saveError = get(this.saveErrorAtom); + const readOnly = get(this.readOnlyAtom); + const spacer: HeaderElem = { elemtype: "div", className: "flex-1", children: [] }; + if (readOnly) { + return [ + spacer, + { elemtype: "text", text: "Read-only", noGrow: true, className: "opacity-50" }, + ] as HeaderElem[]; + } + if (status === "dirty") { + return [spacer, { elemtype: "text", text: "Editing...", noGrow: true }] as HeaderElem[]; + } else if (status === "saved") { + return [ + spacer, + { elemtype: "text", text: "Saved ✓", noGrow: true, className: "text-accent" }, + ] as HeaderElem[]; + } else if (status === "synced") { + return [ + spacer, + { elemtype: "text", text: "Synced ↓", noGrow: true, className: "text-accent" }, + ] as HeaderElem[]; + } else if (status === "error") { + return [ + spacer, + { + elemtype: "text", + text: "Error Saving...", + noGrow: true, + className: "text-errormsg cursor-pointer", + onClick: () => showErrorAlert(saveError), + }, + ] as HeaderElem[]; + } + return []; + }); + + this.wordWrapAtom = jotai.atom((get) => get(waveEnv.getSettingsKeyAtom("editor:wordwrap")) ?? false); + + this.loadFile(); + this.unsubscribeNotes = waveEventSubscribeSingle({ + eventType: "notes:updated", + handler: (event) => this.handleNotesUpdated(event.data), + }); + } + + get viewComponent(): ViewComponent { + return NotesView; + } + + setEditorRef(ref: React.RefObject) { + this.editorRef = ref; + } + + clearSavedTimer() { + if (this.savedClearTimer != null) { + clearTimeout(this.savedClearTimer); + this.savedClearTimer = null; + } + } + + setSavedTimer() { + this.clearSavedTimer(); + this.savedClearTimer = setTimeout(() => { + globalStore.set(this.saveStatusAtom, "idle"); + this.savedClearTimer = null; + }, SavedDisplayMs); + } + + onContentChange(text: string) { + if (this.isApplyingRemoteEdit) { + return; + } + this.clearSavedTimer(); + this.pendingContent = text; + globalStore.set(this.saveStatusAtom, "dirty"); + this.debouncedSave(text); + } + + onCursorChange(pos: number) { + this.debouncedSaveCursorPos(pos); + } + + onBlur() { + if (this.pendingContent != null) { + this.debouncedSave.cancel({ upcomingOnly: true }); + this.saveContent(this.pendingContent); + } + } + + async loadFile() { + try { + const noteData = await this.env.rpc.GetNoteCommand(TabRpcClient); + if (noteData?.error) { + globalStore.set(this.loadErrorAtom, noteData.error); + globalStore.set(this.contentAtom, ""); + globalStore.set(this.filePathAtom, ""); + globalStore.set(this.readOnlyAtom, false); + } else { + globalStore.set(this.contentAtom, noteData?.content ?? ""); + globalStore.set(this.filePathAtom, noteData?.filepath ?? ""); + globalStore.set(this.readOnlyAtom, noteData?.readonly ?? false); + globalStore.set(this.loadErrorAtom, ""); + } + } catch (err) { + globalStore.set(this.loadErrorAtom, `Cannot load notes: ${err?.message ?? String(err)}`); + } finally { + globalStore.set(this.loadedAtom, true); + } + } + + async restoreCursorPos() { + const editor = this.editorRef.current; + if (editor == null) { + return; + } + try { + const rtInfo = await this.env.rpc.GetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("client", ClientModel.getInstance().clientId), + }); + const pos = rtInfo?.["notes:cursorpos"]; + if (!pos) { + return; + } + const editorModel = editor.getModel(); + if (editorModel == null) { + return; + } + const position = editorModel.getPositionAt(pos); + editor.setPosition(position); + editor.revealPosition(position); + } catch (_e) {} + } + + async saveContent(text: string) { + this.pendingContent = null; + console.log("[notes] saveContent start, text.len=", text.length); + try { + await this.env.rpc.WriteNoteCommand(TabRpcClient, { + content: text, + sourceoref: this.myOref, + }); + console.log("[notes] saveContent success, setting saved"); + globalStore.set(this.saveStatusAtom, "saved"); + globalStore.set(this.saveErrorAtom, ""); + this.setSavedTimer(); + } catch (err) { + console.log("[notes] saveContent error:", err); + globalStore.set(this.saveStatusAtom, "error"); + globalStore.set(this.saveErrorAtom, err?.message ?? String(err)); + } + } + + handleNotesUpdated(data: NotesUpdatedData) { + console.log( + "[notes] handleNotesUpdated, sourceoref=", + data?.sourceoref, + "myOref=", + this.myOref, + "currentStatus=", + globalStore.get(this.saveStatusAtom) + ); + if (data?.sourceoref === this.myOref) { + console.log("[notes] handleNotesUpdated skipping (own update)"); + return; + } + this.debouncedSave.cancel({ upcomingOnly: true }); + this.pendingContent = null; + if (data?.error) { + const editor = this.editorRef.current; + if (editor != null) { + const editorModel = editor.getModel(); + if (editorModel != null) { + this.isApplyingRemoteEdit = true; + try { + editorModel.applyEdits([{ range: editorModel.getFullModelRange(), text: "" }]); + } finally { + this.isApplyingRemoteEdit = false; + } + } + } + globalStore.set(this.contentAtom, ""); + globalStore.set(this.filePathAtom, ""); + globalStore.set(this.readOnlyAtom, false); + globalStore.set(this.loadErrorAtom, data.error); + globalStore.set(this.saveStatusAtom, "idle"); + return; + } + const editor = this.editorRef.current; + const content = data?.content ?? ""; + if (editor != null) { + const editorModel = editor.getModel(); + if (editorModel != null) { + this.isApplyingRemoteEdit = true; + try { + editorModel.applyEdits([{ range: editorModel.getFullModelRange(), text: content }]); + } finally { + this.isApplyingRemoteEdit = false; + } + } + } + globalStore.set(this.contentAtom, content); + globalStore.set(this.loadErrorAtom, ""); + if (data?.filepath) { + globalStore.set(this.filePathAtom, data.filepath); + } + if (data?.readonly != null) { + globalStore.set(this.readOnlyAtom, data.readonly); + } + globalStore.set(this.saveStatusAtom, "synced"); + this.setSavedTimer(); + } + + getSettingsMenuItems(): ContextMenuItem[] { + const wordWrap = globalStore.get(this.wordWrapAtom); + const filePath = globalStore.get(this.filePathAtom); + const items: ContextMenuItem[] = []; + if (filePath) { + items.push({ + label: "Copy Notes File Path", + click: () => { + navigator.clipboard.writeText(filePath); + }, + }); + items.push({ type: "separator" }); + } + items.push({ + label: "Word Wrap", + type: "checkbox", + checked: wordWrap, + click: () => { + this.env.rpc.SetConfigCommand(TabRpcClient, { "editor:wordwrap": !wordWrap }); + }, + }); + return items; + } + + giveFocus(): boolean { + if (this.editorRef.current) { + this.editorRef.current.focus(); + return true; + } + return false; + } + + dispose() { + this.debouncedSave.cancel(); + if (this.pendingContent != null) { + this.saveContent(this.pendingContent); + } + this.debouncedSaveCursorPos.cancel(); + this.clearSavedTimer(); + if (this.unsubscribeNotes != null) { + this.unsubscribeNotes(); + } + } +} diff --git a/frontend/app/view/notes/notes.tsx b/frontend/app/view/notes/notes.tsx new file mode 100644 index 0000000000..0efc2ff077 --- /dev/null +++ b/frontend/app/view/notes/notes.tsx @@ -0,0 +1,85 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { MonacoCodeEditor } from "@/app/monaco/monaco-react"; +import { globalStore } from "@/app/store/jotaiStore"; +import { tryReinjectKey } from "@/app/store/keymodel"; +import { NotesViewModel } from "@/app/view/notes/notes-model"; +import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; +import { useAtomValue } from "jotai"; +import type * as MonacoTypes from "monaco-editor"; +import { useCallback, useRef } from "react"; + +export function NotesView({ model }: ViewComponentProps) { + const content = useAtomValue(model.contentAtom); + const loadError = useAtomValue(model.loadErrorAtom); + const loaded = useAtomValue(model.loadedAtom); + const wordWrap = useAtomValue(model.wordWrapAtom); + + const editorRef = useRef(null); + + const handleMount = useCallback( + (editor: MonacoTypes.editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + model.setEditorRef(editorRef); + model.restoreCursorPos(); + editor.onDidBlurEditorWidget(() => { + model.onBlur(); + }); + editor.onDidChangeCursorPosition(() => { + const offset = editor.getModel()?.getOffsetAt(editor.getPosition()); + if (offset != null) { + model.onCursorChange(offset); + } + }); + const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent); + const handled = tryReinjectKey(waveEvent); + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } + }); + if (globalStore.get(model.nodeModel.isFocused)) { + editor.focus(); + } + return () => { + keyDownDisposer.dispose(); + editorRef.current = null; + }; + }, + [model] + ); + + if (!loaded) { + return
Loading...
; + } + + if (loadError) { + return ( +
+
{loadError}
+
+ ); + } + + return ( + model.onContentChange(text)} + onMount={handleMount} + path={`notes-${model.blockId}.md`} + options={{ + wordWrap: wordWrap ? "on" : "off", + minimap: { enabled: false }, + lineNumbers: "off", + folding: false, + scrollBeyondLastLine: false, + quickSuggestions: false, + suggestOnTriggerCharacters: false, + }} + /> + ); +} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..2018dc7d9f 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -1,10 +1,10 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { showAlert, showErrorAlert } from "@/app/modals/alertmodal"; import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { BlockNodeModel } from "@/app/block/blocktypes"; import { appHandleKeyDown } from "@/app/store/keymodel"; -import { modalsModel } from "@/app/store/modalmodel"; import type { TabModel } from "@/app/store/tab-model"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -998,15 +998,11 @@ export class TermViewModel implements ViewModel { } catch (error) { console.error("Failed to save scrollback:", error); const errorMessage = error?.message || "An unknown error occurred"; - modalsModel.pushModal("MessageModal", { - children: `Failed to save session scrollback: ${errorMessage}`, - }); + showErrorAlert(`Failed to save session scrollback: ${errorMessage}`); } }); } else { - modalsModel.pushModal("MessageModal", { - children: "No scrollback content to save.", - }); + showAlert({ title: "Save Session", message: "No scrollback content to save." }); } } }, diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 123b9d3144..a5e1b730ac 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -23,6 +23,7 @@ export const PreviewClientId = crypto.randomUUID(); export const WebBlockId = crypto.randomUUID(); export const SysinfoBlockId = crypto.randomUUID(); export const ProcessViewerBlockId = crypto.randomUUID(); +export const NotesBlockId = crypto.randomUUID(); // What works "out of the box" in the mock environment (no MockEnv overrides needed): // @@ -419,6 +420,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { view: "processviewer", }, } as Block, + [`block:${NotesBlockId}`]: { + otype: "block", + oid: NotesBlockId, + version: 1, + meta: { + view: "notes", + }, + } as Block, }; const defaultAtoms: Partial = { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), diff --git a/frontend/preview/previews/alertmodal.preview.tsx b/frontend/preview/previews/alertmodal.preview.tsx new file mode 100644 index 0000000000..3f9687e523 --- /dev/null +++ b/frontend/preview/previews/alertmodal.preview.tsx @@ -0,0 +1,81 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { AlertModal } from "@/app/modals/alertmodal"; +import { useState } from "react"; + +type PreviewEntry = { + label: string; + message: string; + title?: string; + icon?: string; + iconClassName?: string; +}; + +const previews: PreviewEntry[] = [ + { + label: "Default", + message: "A much longer message that might wrap across multiple lines in the modal body to test layout behavior with extended content.", + }, + { + label: "Short message", + message: "Short error.", + }, + { + label: "Error", + title: "Save Failed", + message: "Error saving file: EACCES: permission denied, open '/root/notes.md'", + icon: "circle-exclamation", + iconClassName: "text-error", + }, + { + label: "Error + custom icon", + title: "Connection Failed", + message: "Unable to connect to remote host. Check your SSH configuration and try again.", + icon: "plug-circle-xmark", + iconClassName: "text-error", + }, + { + label: "Warning", + title: "Unsaved Changes", + message: "You have unsaved changes. They will be lost if you continue.", + icon: "triangle-exclamation", + iconClassName: "text-warning", + }, +]; + +export function AlertModalPreview() { + const [key, setKey] = useState(0); + const [current, setCurrent] = useState(null); + + return ( +
+

Click a button to open the AlertModal.

+
+ {previews.map((p, i) => ( + + ))} +
+ {current && ( + setCurrent(null)} + > + {current.message} + + )} +
+ ); +} diff --git a/frontend/preview/previews/notes.preview.tsx b/frontend/preview/previews/notes.preview.tsx new file mode 100644 index 0000000000..78d91575fd --- /dev/null +++ b/frontend/preview/previews/notes.preview.tsx @@ -0,0 +1,92 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { stringToBase64 } from "@/util/util"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { NotesBlockId } from "../mock/mockwaveenv"; +import { useRpcOverride } from "../mock/use-rpc-override"; + +const PreviewNodeId = "preview-notes-node"; + +const MockContent = `# My Notes + +This is a **preview** of the notes view. + +- Item one +- Item two +- Item three + +> Autosave is debounced — changes save after 1 second of inactivity. +`; + +export default function NotesPreview() { + const [failRead, setFailRead] = React.useState(false); + const [failWrite, setFailWrite] = React.useState(false); + const [blockKey, setBlockKey] = React.useState(0); + + const failReadRef = React.useRef(false); + const failWriteRef = React.useRef(false); + + const nodeModel = React.useMemo( + () => + makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId: NotesBlockId, + innerRect: { width: "700px", height: "500px" }, + numLeafs: 1, + }), + [] + ); + + useRpcOverride("FileReadCommand", async () => { + if (failReadRef.current) throw new Error("Permission denied: cannot read ~/notes.md"); + return { data64: stringToBase64(MockContent) }; + }); + + useRpcOverride("FileWriteCommand", async () => { + if (failWriteRef.current) throw new Error("Disk full: cannot write ~/notes.md"); + return null; + }); + + function toggleFailRead(val: boolean) { + failReadRef.current = val; + setFailRead(val); + setBlockKey((k) => k + 1); + } + + function toggleFailWrite(val: boolean) { + failWriteRef.current = val; + setFailWrite(val); + } + + return ( +
+
notes block (mock RPC — FileReadCommand / FileWriteCommand)
+
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index 3a0523c8ce..789ce43cbd 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -24,6 +24,7 @@ --color-accent-800: rgb(22, 81, 35); --color-accent-900: rgb(15, 61, 29); --color-error: rgb(229, 77, 46); + --color-errormsg: rgb(255, 80, 60); --color-warning: rgb(224, 185, 86); --color-success: rgb(78, 154, 6); --color-panel: rgba(31, 33, 31, 0.5); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..2df00dd053 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -793,6 +793,12 @@ declare global { bindings: {[key: string]: string}; }; + // wshrpc.CommandWriteNoteData + type CommandWriteNoteData = { + content: string; + sourceoref: string; + }; + // wshrpc.CommandWriteTempFileData type CommandWriteTempFileData = { filename: string; @@ -1225,6 +1231,23 @@ declare global { color: string; }; + // wshrpc.NoteData + type NoteData = { + content: string; + readonly?: boolean; + filepath?: string; + error?: string; + }; + + // wshrpc.NotesUpdatedData + type NotesUpdatedData = { + content: string; + sourceoref: string; + readonly?: boolean; + filepath?: string; + error?: string; + }; + // waveobj.ORef type ORef = string; @@ -1249,6 +1272,7 @@ declare global { "waveai:chatid"?: string; "waveai:mode"?: string; "waveai:maxoutputtokens"?: number; + "notes:cursorpos"?: number; }; // wshrpc.PathCommandData @@ -1484,6 +1508,7 @@ declare global { "tsunami:sdkreplacepath"?: string; "tsunami:sdkversion"?: string; "tsunami:gopath"?: string; + "notes:path"?: string; }; // waveobj.StickerClickOptsType @@ -1589,6 +1614,7 @@ declare global { "debug:panictype"?: string; "block:view"?: string; "block:controller"?: string; + "block:subblock"?: boolean; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts index c3e5bd7822..4e6a4ae4bc 100644 --- a/frontend/types/waveevent.d.ts +++ b/frontend/types/waveevent.d.ts @@ -26,6 +26,7 @@ declare global { | "waveai:modeconfig" | "block:jobstatus" | "badge" + | "notes:updated" ; type WaveEvent = { @@ -53,7 +54,8 @@ declare global { { event: "tsunami:updatemeta"; data?: AppMeta; } | { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } | { event: "block:jobstatus"; data?: BlockJobStatusData; } | - { event: "badge"; data?: BadgeEvent; } + { event: "badge"; data?: BadgeEvent; } | + { event: "notes:updated"; data?: NotesUpdatedData; } ); } diff --git a/pkg/notesmanager/notesmanager.go b/pkg/notesmanager/notesmanager.go new file mode 100644 index 0000000000..7943efc7e6 --- /dev/null +++ b/pkg/notesmanager/notesmanager.go @@ -0,0 +1,330 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package notesmanager + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type NotesManager struct { + lock sync.Mutex + currentContent string + lastWrittenModTime time.Time + watcher *fsnotify.Watcher + configuredPath string + notesPath string + tmpPath string + initialized bool + initErr error // fatal: reads and writes both fail + readOnlyErr error // non-fatal: reads work, writes fail + handlerRegistered bool +} + +var instance *NotesManager +var instanceLock sync.Mutex + +func GetNotesManager() *NotesManager { + instanceLock.Lock() + defer instanceLock.Unlock() + if instance == nil { + instance = &NotesManager{} + } + return instance +} + +func notesFilePaths() (string, string, string, error) { + configuredPath := wconfig.GetWatcher().GetFullConfig().Settings.NotesPath + if configuredPath == "" { + configuredPath = "~/notes.md" + } + notesPath, err := wavebase.ExpandHomeDir(configuredPath) + if err != nil { + return configuredPath, "", "", fmt.Errorf("expanding notes path: %w", err) + } + if !filepath.IsAbs(notesPath) { + return configuredPath, "", "", fmt.Errorf("notes path %q must be an absolute path (fix via config key \"notes:path\")", configuredPath) + } + tmpPath := notesPath + ".tmp" + return configuredPath, notesPath, tmpPath, nil +} + +func normalizeContent(s string) string { + return strings.ReplaceAll(s, "\r\n", "\n") +} + +// ensureInit lazily initializes the file watcher and reads the current content. +// Must be called with nm.lock held. +func (nm *NotesManager) ensureInit() error { + if nm.initialized { + return nm.initErr + } + nm.initialized = true + nm.initErr = nm.doInit() + if nm.initErr != nil { + log.Printf("notesmanager: init error (configured path: %q): %v\n", nm.configuredPath, nm.initErr) + } else { + log.Printf("notesmanager: init success, path: %s\n", nm.notesPath) + } + return nm.initErr +} + +func (nm *NotesManager) doInit() error { + if !nm.handlerRegistered { + wconfig.GetWatcher().RegisterUpdateHandler(nm.onConfigUpdate) + nm.handlerRegistered = true + } + + configuredPath, notesPath, tmpPath, err := notesFilePaths() + if err != nil { + nm.configuredPath = configuredPath + return err + } + nm.configuredPath = configuredPath + + // reject if path is a directory or otherwise not a regular file + if info, statErr := os.Stat(notesPath); statErr == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("notes path %q is not a regular file", notesPath) + } + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("cannot stat notes file: %w", statErr) + } + + // validate parent directory is accessible + parentDir := filepath.Dir(notesPath) + if info, statErr := os.Stat(parentDir); statErr != nil { + return fmt.Errorf("notes directory %q is not accessible: %w", parentDir, statErr) + } else if !info.IsDir() { + return fmt.Errorf("notes parent path %q is not a directory", parentDir) + } + + nm.notesPath = notesPath + nm.tmpPath = tmpPath + + // probe directory write+delete permissions using the tmp path + probeWriteErr := os.WriteFile(tmpPath, []byte{}, 0644) + if probeWriteErr == nil { + probeWriteErr = os.Remove(tmpPath) + } + if probeWriteErr != nil { + nm.readOnlyErr = fmt.Errorf("notes directory %q is not writable: %w", parentDir, probeWriteErr) + } + + data, err := os.ReadFile(notesPath) + if os.IsNotExist(err) { + if nm.readOnlyErr == nil { + // create blank file now that we've confirmed write access + if writeErr := os.WriteFile(notesPath, []byte{}, 0644); writeErr != nil { + return fmt.Errorf("cannot create notes file %q: %w", notesPath, writeErr) + } + } + } else if err != nil { + return fmt.Errorf("cannot read notes file: %w", err) + } else { + nm.currentContent = normalizeContent(string(data)) + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("creating file watcher: %w", err) + } + // watch the parent directory so renames into place are caught + if watchErr := watcher.Add(parentDir); watchErr != nil { + watcher.Close() + return fmt.Errorf("adding watch path: %w", watchErr) + } + nm.watcher = watcher + + go nm.watchLoop() + return nil +} + +func (nm *NotesManager) watchLoop() { + for { + select { + case event, ok := <-nm.watcher.Events: + if !ok { + return + } + if filepath.Clean(event.Name) != filepath.Clean(nm.notesPath) { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { + continue + } + nm.handleExternalChange() + case _, ok := <-nm.watcher.Errors: + if !ok { + return + } + } + } +} + +func (nm *NotesManager) handleExternalChange() { + data, err := os.ReadFile(nm.notesPath) + if err != nil && !os.IsNotExist(err) { + return + } + var content string + if err == nil { + content = normalizeContent(string(data)) + } + + var modTime time.Time + if info, statErr := os.Stat(nm.notesPath); statErr == nil { + modTime = info.ModTime() + } + + nm.lock.Lock() + if !modTime.IsZero() && modTime.Equal(nm.lastWrittenModTime) { + nm.lock.Unlock() + return + } + if content == nm.currentContent { + nm.lock.Unlock() + return + } + nm.currentContent = content + nm.lock.Unlock() + + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_NotesUpdated, + Data: wshrpc.NotesUpdatedData{ + Content: content, + SourceOref: "external", + ReadOnly: nm.readOnlyErr != nil, + FilePath: nm.notesPath, + }, + }) +} + +func (nm *NotesManager) resetInit() { + if nm.watcher != nil { + nm.watcher.Close() + nm.watcher = nil + } + nm.initialized = false + nm.initErr = nil + nm.readOnlyErr = nil + nm.configuredPath = "" + nm.notesPath = "" + nm.tmpPath = "" + nm.lastWrittenModTime = time.Time{} + nm.currentContent = "" +} + +func (nm *NotesManager) onConfigUpdate(_ wconfig.FullConfigType) { + _, newNotesPath, _, pathErr := notesFilePaths() + + nm.lock.Lock() + if pathErr == nil && newNotesPath == nm.notesPath && nm.initErr == nil { + nm.lock.Unlock() + return + } + nm.resetInit() + nm.lock.Unlock() + + if pathErr != nil { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_NotesUpdated, + Data: wshrpc.NotesUpdatedData{ + SourceOref: "pathchange", + Error: pathErr.Error(), + }, + }) + return + } + + nm.lock.Lock() + initErr := nm.ensureInit() + content := nm.currentContent + notesPath := nm.notesPath + readOnly := nm.readOnlyErr != nil + nm.lock.Unlock() + + if initErr != nil { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_NotesUpdated, + Data: wshrpc.NotesUpdatedData{ + SourceOref: "pathchange", + Error: initErr.Error(), + }, + }) + return + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_NotesUpdated, + Data: wshrpc.NotesUpdatedData{ + Content: content, + SourceOref: "pathchange", + ReadOnly: readOnly, + FilePath: notesPath, + }, + }) +} + +func (nm *NotesManager) GetNote(ctx context.Context) (wshrpc.NoteData, error) { + nm.lock.Lock() + defer nm.lock.Unlock() + if err := nm.ensureInit(); err != nil { + return wshrpc.NoteData{}, err + } + return wshrpc.NoteData{ + Content: nm.currentContent, + ReadOnly: nm.readOnlyErr != nil, + FilePath: nm.notesPath, + }, nil +} + + +func (nm *NotesManager) WriteNote(ctx context.Context, data wshrpc.CommandWriteNoteData) error { + content := normalizeContent(data.Content) + + nm.lock.Lock() + defer nm.lock.Unlock() + if err := nm.ensureInit(); err != nil { + return err + } + if nm.readOnlyErr != nil { + return nm.readOnlyErr + } + + if err := os.WriteFile(nm.tmpPath, []byte(content), 0644); err != nil { + return fmt.Errorf("writing notes temp file: %w", err) + } + if err := os.Rename(nm.tmpPath, nm.notesPath); err != nil { + return fmt.Errorf("renaming notes temp file: %w", err) + } + + info, err := os.Stat(nm.notesPath) + if err != nil { + return fmt.Errorf("stat after write: %w", err) + } + nm.lastWrittenModTime = info.ModTime() + nm.currentContent = content + + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_NotesUpdated, + Data: wshrpc.NotesUpdatedData{ + Content: content, + SourceOref: data.SourceOref, + FilePath: nm.notesPath, + }, + }) + return nil +} diff --git a/pkg/tsgen/tsgenevent.go b/pkg/tsgen/tsgenevent.go index 6e1c08e981..7f4753e920 100644 --- a/pkg/tsgen/tsgenevent.go +++ b/pkg/tsgen/tsgenevent.go @@ -41,6 +41,7 @@ var WaveEventDataTypes = map[string]reflect.Type{ wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}), wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}), wps.Event_Badge: reflect.TypeOf(baseds.BadgeEvent{}), + wps.Event_NotesUpdated: reflect.TypeOf(wshrpc.NotesUpdatedData{}), } func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string { diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index a7b35bbd86..0f991a9f85 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -26,4 +26,6 @@ type ObjRTInfo struct { WaveAIChatId string `json:"waveai:chatid,omitempty"` WaveAIMode string `json:"waveai:mode,omitempty"` WaveAIMaxOutputTokens int `json:"waveai:maxoutputtokens,omitempty"` + + NotesCursorPos int `json:"notes:cursorpos,omitempty"` } diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index d8847cabf2..abb7f15a58 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -40,5 +40,6 @@ "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced", - "preview:defaultsort": "name" + "preview:defaultsort": "name", + "notes:path": "~/notes.md" } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 7d5bba5d9d..97e0566785 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -131,5 +131,7 @@ const ( ConfigKey_TsunamiSdkReplacePath = "tsunami:sdkreplacepath" ConfigKey_TsunamiSdkVersion = "tsunami:sdkversion" ConfigKey_TsunamiGoPath = "tsunami:gopath" + + ConfigKey_NotesPath = "notes:path" ) diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 67118b1670..5f97c3a5f3 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -182,6 +182,8 @@ type SettingsType struct { TsunamiSdkReplacePath string `json:"tsunami:sdkreplacepath,omitempty"` TsunamiSdkVersion string `json:"tsunami:sdkversion,omitempty"` TsunamiGoPath string `json:"tsunami:gopath,omitempty"` + + NotesPath string `json:"notes:path,omitempty"` } func (s *SettingsType) GetAiSettings() *AiSettingsType { diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 0077ec9d5e..2951f74c73 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -34,6 +34,7 @@ const ( Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData Event_Badge = "badge" // type: baseds.BadgeEvent + Event_NotesUpdated = "notes:updated" // type: wshrpc.NotesUpdatedData ) var AllEvents []string = []string{ @@ -56,6 +57,7 @@ var AllEvents []string = []string{ Event_AIModeConfig, Event_BlockJobStatus, Event_Badge, + Event_NotesUpdated, } type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..1493e205a0 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -442,6 +442,12 @@ func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wsh return resp, err } +// command "getnote", wshserver.GetNoteCommand +func GetNoteCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.NoteData, error) { + resp, err := sendRpcRequestCallHelper[wshrpc.NoteData](w, "getnote", nil, opts) + return resp, err +} + // command "getrtinfo", wshserver.GetRTInfoCommand func GetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandGetRTInfoData, opts *wshrpc.RpcOpts) (*waveobj.ObjRTInfo, error) { resp, err := sendRpcRequestCallHelper[*waveobj.ObjRTInfo](w, "getrtinfo", data, opts) @@ -1042,6 +1048,12 @@ func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAp return err } +// command "writenote", wshserver.WriteNoteCommand +func WriteNoteCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteNoteData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "writenote", data, opts) + return err +} + // command "writetempfile", wshserver.WriteTempFileCommand func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..e899a369f1 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -97,6 +97,8 @@ type WshRpcInterface interface { UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) + WriteNoteCommand(ctx context.Context, data CommandWriteNoteData) error + GetNoteCommand(ctx context.Context) (NoteData, error) // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) @@ -341,7 +343,6 @@ type CommandEventReadHistoryData struct { MaxItems int `json:"maxitems"` } - type CpuDataRequest struct { Id string `json:"id"` Count int `json:"count"` @@ -921,6 +922,26 @@ type CommandRemoteProcessListData struct { KeepAlive bool `json:"keepalive,omitempty"` } +type CommandWriteNoteData struct { + Content string `json:"content"` + SourceOref string `json:"sourceoref"` +} + +type NoteData struct { + Content string `json:"content"` + ReadOnly bool `json:"readonly,omitempty"` + FilePath string `json:"filepath,omitempty"` + Error string `json:"error,omitempty"` +} + +type NotesUpdatedData struct { + Content string `json:"content"` + SourceOref string `json:"sourceoref"` + ReadOnly bool `json:"readonly,omitempty"` + FilePath string `json:"filepath,omitempty"` + Error string `json:"error,omitempty"` +} + type CommandRemoteProcessSignalData struct { Pid int32 `json:"pid"` Signal string `json:"signal"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..46af79e90f 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -33,6 +33,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/notesmanager" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" @@ -1472,6 +1473,14 @@ func (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEve return wcore.GetAllBadges(), nil } +func (ws *WshServer) WriteNoteCommand(ctx context.Context, data wshrpc.CommandWriteNoteData) error { + return notesmanager.GetNotesManager().WriteNote(ctx, data) +} + +func (ws *WshServer) GetNoteCommand(ctx context.Context) (wshrpc.NoteData, error) { + return notesmanager.GetNotesManager().GetNote(ctx) +} + func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) { result := make(map[string]string) for _, name := range names { diff --git a/schema/settings.json b/schema/settings.json index f341a0f365..6b2d3b4c84 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -351,6 +351,9 @@ }, "tsunami:gopath": { "type": "string" + }, + "notes:path": { + "type": "string" } }, "additionalProperties": false,