diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 698f640429..a5bb77b20a 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -88,7 +88,7 @@ function getViewElem( ); } if (blockView === "web") { - return ; + return ; } if (blockView === "waveai") { return ; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index f71ce7cfae..33c33c1263 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -607,7 +607,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { "--magnified-block-blur": `${magnifiedBlockBlur}px`, } as React.CSSProperties } - inert={preview ? "1" : undefined} + inert={preview ? "1" : undefined} // this does exist in the DOM, just not in react > {preview || viewModel == null ? null : ( diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 4ced3db7cb..142d4c5212 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -397,7 +397,7 @@ function registerGlobalKeys() { }); const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view - allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft"); + allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); getApi().registerGlobalWebviewKeys(allKeys); } diff --git a/frontend/app/suggestion/suggestion.tsx b/frontend/app/suggestion/suggestion.tsx new file mode 100644 index 0000000000..84b6340204 --- /dev/null +++ b/frontend/app/suggestion/suggestion.tsx @@ -0,0 +1,317 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms } from "@/app/store/global"; +import { isBlank, makeIconClass } from "@/util/util"; +import { offset, useFloating } from "@floating-ui/react"; +import clsx from "clsx"; +import { Atom, useAtomValue } from "jotai"; +import React, { ReactNode, useEffect, useId, useRef, useState } from "react"; + +interface SuggestionControlProps { + anchorRef: React.RefObject; + isOpen: boolean; + onClose: () => void; + onSelect: (item: SuggestionType, queryStr: string) => void; + onTab?: (item: SuggestionType, queryStr: string) => string; + fetchSuggestions: SuggestionsFnType; + className?: string; + placeholderText?: string; + children?: React.ReactNode; +} + +type BlockHeaderSuggestionControlProps = Omit & { + blockRef: React.RefObject; + openAtom: Atom; +}; + +const SuggestionControl: React.FC = ({ + anchorRef, + isOpen, + onClose, + onSelect, + fetchSuggestions, + className, + children, +}) => { + if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; + + return ; +}; + +function highlightPositions(target: string, positions: number[]): ReactNode[] { + if (target == null) { + return []; + } + if (positions == null) { + return [target]; + } + const result: ReactNode[] = []; + let targetIndex = 0; + let posIndex = 0; + + while (targetIndex < target.length) { + if (posIndex < positions.length && targetIndex === positions[posIndex]) { + result.push( + + {target[targetIndex]} + + ); + posIndex++; + } else { + result.push(target[targetIndex]); + } + targetIndex++; + } + return result; +} + +function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] { + if (mimeType == null) { + return [null, null]; + } + while (mimeType.length > 0) { + const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null; + const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null; + if (icon != null) { + return [icon, iconColor]; + } + mimeType = mimeType.slice(0, -1); + } + return [null, null]; +} + +const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => { + if (suggestion.iconsrc) { + return favicon; + } + if (suggestion.icon) { + const iconClass = makeIconClass(suggestion.icon, true); + const iconColor = suggestion.iconcolor; + return ; + } + if (suggestion.type === "url") { + const iconClass = makeIconClass("globe", true); + const iconColor = suggestion.iconcolor; + return ; + } else if (suggestion.type === "file") { + // For file suggestions, use the existing logic. + const fullConfig = useAtomValue(atoms.fullConfigAtom); + let icon: string = null; + let iconColor: string = null; + if (icon == null && suggestion["file:mimetype"] != null) { + [icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]); + } + const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); + return ; + } + const iconClass = makeIconClass("file", true); + return ; +}; + +const SuggestionContent: React.FC<{ + suggestion: SuggestionType; +}> = ({ suggestion }) => { + if (!isBlank(suggestion.subtext)) { + return ( +
+ {/* Title on the first line, with highlighting */} +
{highlightPositions(suggestion.display, suggestion.matchpos)}
+ {/* Subtext on the second line in a smaller, grey style */} +
+ {highlightPositions(suggestion.subtext, suggestion.submatchpos)} +
+
+ ); + } + return {highlightPositions(suggestion.display, suggestion.matchpos)}; +}; + +const BlockHeaderSuggestionControl: React.FC = (props) => { + const [headerElem, setHeaderElem] = useState(null); + const isOpen = useAtomValue(props.openAtom); + + useEffect(() => { + if (props.blockRef.current == null) { + setHeaderElem(null); + return; + } + const headerElem = props.blockRef.current.querySelector("[data-role='block-header']"); + setHeaderElem(headerElem as HTMLElement); + }, [props.blockRef.current]); + + const newClass = clsx(props.className, "rounded-t-none"); + return ; +}; + +/** + * The empty state component that can be used as a child of SuggestionControl. + * If no children are provided to SuggestionControl, this default empty state will be used. + */ +const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( +
+ {children ?? No Suggestions} +
+ ); +}; + +const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( +
+ {children ?? No Suggestions} +
+ ); +}; + +interface SuggestionControlInnerProps extends Omit {} + +const SuggestionControlInner: React.FC = ({ + anchorRef, + onClose, + onSelect, + onTab, + fetchSuggestions, + className, + placeholderText, + children, +}) => { + const widgetId = useId(); + const [query, setQuery] = useState(""); + const reqNumRef = useRef(0); + let [suggestions, setSuggestions] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [fetched, setFetched] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const { refs, floatingStyles, middlewareData } = useFloating({ + placement: "bottom", + strategy: "absolute", + middleware: [offset(-1)], + }); + const emptyStateChild = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === SuggestionControlNoResults + ); + const noDataChild = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === SuggestionControlNoData + ); + + useEffect(() => { + refs.setReference(anchorRef.current); + }, [anchorRef.current]); + + useEffect(() => { + reqNumRef.current++; + fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => { + if (results.reqnum !== reqNumRef.current) { + return; + } + setSuggestions(results.suggestions ?? []); + setFetched(true); + }); + }, [query, fetchSuggestions]); + + useEffect(() => { + return () => { + reqNumRef.current++; + fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true }); + }; + }, []); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose, anchorRef]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && selectedIndex >= 0) { + e.preventDefault(); + onSelect(suggestions[selectedIndex], query); + onClose(); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } else if (e.key === "Tab") { + e.preventDefault(); + const suggestion = suggestions[selectedIndex]; + if (suggestion != null) { + const tabResult = onTab?.(suggestion, query); + if (tabResult != null) { + setQuery(tabResult); + } + } + } + }; + return ( +
+
+ { + setQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + className="w-full bg-gray-900 text-gray-100 px-4 py-2 rounded-md border border-gray-700 focus:outline-none focus:border-accent placeholder-secondary" + placeholder={placeholderText} + /> +
+ {fetched && + (suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion, index) => ( +
{ + onSelect(suggestion, query); + onClose(); + }} + > + + +
+ ))} +
+ ) : ( + // Render the empty state (either a provided child or the default) +
+ {query === "" + ? (noDataChild ?? ) + : (emptyStateChild ?? )} +
+ ))} +
+ ); +}; + +export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults }; diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/typeahead/typeahead.tsx deleted file mode 100644 index cb4fa93ca3..0000000000 --- a/frontend/app/typeahead/typeahead.tsx +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { atoms } from "@/app/store/global"; -import { isBlank, makeIconClass } from "@/util/util"; -import { offset, useFloating } from "@floating-ui/react"; -import clsx from "clsx"; -import { useAtomValue } from "jotai"; -import React, { ReactNode, useEffect, useId, useRef, useState } from "react"; - -interface TypeaheadProps { - anchorRef: React.RefObject; - isOpen: boolean; - onClose: () => void; - onSelect: (item: SuggestionType, queryStr: string) => void; - fetchSuggestions: SuggestionsFnType; - className?: string; - placeholderText?: string; -} - -const Typeahead: React.FC = ({ anchorRef, isOpen, onClose, onSelect, fetchSuggestions, className }) => { - if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; - - return ; -}; - -function highlightSearchMatch(target: string, search: string, highlightFn: (char: string) => ReactNode): ReactNode[] { - if (!search || !target) return [target]; - - const result: ReactNode[] = []; - let targetIndex = 0; - let searchIndex = 0; - - while (targetIndex < target.length) { - // If we've matched all search chars, add remaining target string - if (searchIndex >= search.length) { - result.push(target.slice(targetIndex)); - break; - } - - // If current chars match - if (target[targetIndex].toLowerCase() === search[searchIndex].toLowerCase()) { - // Add highlighted character - result.push(highlightFn(target[targetIndex])); - searchIndex++; - targetIndex++; - } else { - // Add non-matching character - result.push(target[targetIndex]); - targetIndex++; - } - } - return result; -} - -function defaultHighlighter(target: string, search: string): ReactNode[] { - return highlightSearchMatch(target, search, (char) => {char}); -} - -function highlightPositions(target: string, positions: number[]): ReactNode[] { - const result: ReactNode[] = []; - let targetIndex = 0; - let posIndex = 0; - - while (targetIndex < target.length) { - if (posIndex < positions.length && targetIndex === positions[posIndex]) { - result.push({target[targetIndex]}); - posIndex++; - } else { - result.push(target[targetIndex]); - } - targetIndex++; - } - return result; -} - -function getHighlightedText(suggestion: SuggestionType, highlightTerm: string): ReactNode[] { - if (suggestion.matchpositions != null && suggestion.matchpositions.length > 0) { - return highlightPositions(suggestion["file:name"], suggestion.matchpositions); - } - if (isBlank(highlightTerm)) { - return [suggestion["file:name"]]; - } - return defaultHighlighter(suggestion["file:name"], highlightTerm); -} - -function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] { - if (mimeType == null) { - return [null, null]; - } - while (mimeType.length > 0) { - const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null; - const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null; - if (icon != null) { - return [icon, iconColor]; - } - mimeType = mimeType.slice(0, -1); - } - return [null, null]; -} - -const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => { - const fullConfig = useAtomValue(atoms.fullConfigAtom); - let icon = suggestion.icon; - let iconColor: string = null; - if (icon == null && suggestion["file:mimetype"] != null) { - [icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]); - } - if (suggestion.iconcolor != null) { - iconColor = suggestion.iconcolor; - } - const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); - return ; -}; - -const TypeaheadInner: React.FC> = ({ - anchorRef, - onClose, - onSelect, - fetchSuggestions, - className, - placeholderText, -}) => { - const widgetId = useId(); - const [query, setQuery] = useState(""); - const reqNumRef = useRef(0); - const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [highlightTerm, setHighlightTerm] = useState(""); - const [fetched, setFetched] = useState(false); - const inputRef = useRef(null); - const dropdownRef = useRef(null); - const { refs, floatingStyles, middlewareData } = useFloating({ - placement: "bottom", - strategy: "absolute", - middleware: [offset(5)], - }); - - useEffect(() => { - if (anchorRef.current == null) { - refs.setReference(null); - return; - } - const headerElem = anchorRef.current.querySelector("[data-role='block-header']"); - refs.setReference(headerElem); - }, [anchorRef.current]); - - useEffect(() => { - reqNumRef.current++; - fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => { - if (results.reqnum != reqNumRef.current) { - return; - } - setSuggestions(results.suggestions ?? []); - setHighlightTerm(results.highlightterm ?? ""); - setFetched(true); - }); - }, [query, fetchSuggestions]); - - useEffect(() => { - return () => { - reqNumRef.current++; - fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true }); - }; - }, []); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - onClose(); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onClose, anchorRef]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - } else if (e.key === "Enter" && selectedIndex >= 0) { - e.preventDefault(); - onSelect(suggestions[selectedIndex], query); - onClose(); - } else if (e.key === "Escape") { - e.preventDefault(); - onClose(); - } else if (e.key === "Tab") { - e.preventDefault(); - const suggestion = suggestions[selectedIndex]; - if (suggestion != null) { - // set the query to the suggestion - if (suggestion["file:mimetype"] == "directory") { - setQuery(suggestion["file:name"] + "/"); - } else { - setQuery(suggestion["file:name"]); - } - } - } - }; - - return ( -
-
- { - setQuery(e.target.value); - setSelectedIndex(0); - }} - onKeyDown={handleKeyDown} - className="w-full bg-gray-900 text-gray-100 px-4 py-2 rounded-md - border border-gray-700 focus:outline-none focus:border-blue-500 - placeholder-gray-500" - placeholder={placeholderText} - /> -
- {fetched && suggestions.length > 0 && ( -
- {suggestions.map((suggestion, index) => ( -
{ - onSelect(suggestion, query); - onClose(); - }} - > - - {getHighlightedText(suggestion, highlightTerm)} -
- ))} -
- )} -
- ); -}; - -export { Typeahead }; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index f2c9bd7ddc..01675288a5 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -9,7 +9,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { Typeahead } from "@/app/typeahead/typeahead"; +import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; import { @@ -1124,13 +1124,19 @@ function PreviewView({ model: PreviewModel; }) { const connStatus = useAtomValue(model.connStatus); - const openFileModal = useAtomValue(model.openFileModal); if (connStatus?.status != "connected") { return null; } const handleSelect = (s: SuggestionType) => { model.handleOpenFile(s["file:path"]); }; + const handleTab = (s: SuggestionType, query: string): string => { + if (s["mime:type"] == "directory") { + return s["file:name"] + "/"; + } else { + return s["file:name"]; + } + }; const fetchSuggestionsFn = async (query, ctx) => { return await fetchSuggestions(model, query, ctx); }; @@ -1142,11 +1148,12 @@ function PreviewView({ - model.updateOpenFileModalAndError(false)} onSelect={handleSelect} + onTab={handleTab} fetchSuggestions={fetchSuggestionsFn} placeholderText="Open File..." /> diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index c3ca0d0104..8b96a674ef 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -3,11 +3,16 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import { Search, useSearch } from "@/app/element/search"; -import { getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; +import { createBlock, getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { ObjectService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { + BlockHeaderSuggestionControl, + SuggestionControlNoData, + SuggestionControlNoResults, +} from "@/app/suggestion/suggestion"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; @@ -54,6 +59,7 @@ export class WebViewModel implements ViewModel { domReady: PrimitiveAtom; hideNav: Atom; searchAtoms?: SearchAtoms; + typeaheadOpen: PrimitiveAtom; constructor(blockId: string, nodeModel: BlockNodeModel) { this.nodeModel = nodeModel; @@ -78,6 +84,7 @@ export class WebViewModel implements ViewModel { this.webviewRef = createRef(); this.domReady = atom(false); this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav"); + this.typeaheadOpen = atom(false); this.mediaPlaying = atom(false); this.mediaMuted = atom(false); @@ -229,6 +236,23 @@ export class WebViewModel implements ViewModel { } } + setTypeaheadOpen(open: boolean) { + globalStore.set(this.typeaheadOpen, open); + } + + async fetchBookmarkSuggestions( + query: string, + reqContext: SuggestionRequestContext + ): Promise { + const result = await RpcApi.FetchSuggestionsCommand(TabRpcClient, { + suggestiontype: "bookmark", + query, + widgetid: reqContext.widgetid, + reqnum: reqContext.reqnum, + }); + return result; + } + handleUrlWrapperMouseOver(e: React.MouseEvent) { const urlInputFocused = globalStore.get(this.urlInputFocused); if (e.type === "mouseover" && !urlInputFocused) { @@ -456,6 +480,11 @@ export class WebViewModel implements ViewModel { this.handleForward(null); return true; } + if (checkKeyPressed(e, "Cmd:o")) { + const curVal = globalStore.get(this.typeaheadOpen); + globalStore.set(this.typeaheadOpen, !curVal); + return true; + } return false; } @@ -570,9 +599,70 @@ interface WebViewProps { blockId: string; model: WebViewModel; onFailLoad?: (url: string) => void; + blockRef: React.RefObject; } -const WebView = memo(({ model, onFailLoad }: WebViewProps) => { +const BookmarkTypeahead = memo( + ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + const openBookmarksJson = () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/presets/bookmarks.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, false, true); + model.setTypeaheadOpen(false); + }); + }; + return ( + model.setTypeaheadOpen(false)} + onSelect={(suggestion) => { + if (suggestion == null || suggestion.type != "url") { + return; + } + model.loadUrl(suggestion["url:url"], "bookmark-typeahead"); + }} + fetchSuggestions={model.fetchBookmarkSuggestions} + placeholderText="Open Bookmark..." + > + +
+

No Bookmarks Configured

+

+ Edit your bookmarks.json file to configure bookmarks. +

+ +
+
+ + +
+

No matching bookmarks

+ +
+
+
+ ); + } +); + +const WebView = memo(({ model, onFailLoad, blockRef }: WebViewProps) => { const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); @@ -581,6 +671,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => { metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); const metaUrlRef = useRef(metaUrl); const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; + const webPartition = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:partition")) || undefined; // Search const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model }); @@ -789,6 +880,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => { preload={getWebviewPreloadUrl()} // @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean. allowpopups="true" + partition={webPartition} /> {errorText && (
@@ -796,6 +888,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
)} + ); }); diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 5836277cfd..49cc02f040 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -80,7 +80,7 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => { return (
handleWidgetSelect(widget)} diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index b755802d77..578567a921 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -23,9 +23,13 @@ --color-warning: rgb(224, 185, 86); --color-success: rgb(78, 154, 6); --color-panel: rgba(31, 33, 31, 0.5); - --color-highlightbg: rgba(255, 255, 255, 0.2); --color-hover: rgba(255, 255, 255, 0.1); --color-border: rgba(255, 255, 255, 0.16); + --color-modalbg: #232323; + --color-accentbg: rgba(88, 193, 66, 0.5); + --color-hoverbg: rgba(255, 255, 255, 0.2); + --color-accent: rgb(88, 193, 66); + --color-accenthover: rgb(118, 223, 96); --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index eacb9e4724..f9e8be741d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -387,7 +387,6 @@ declare global { type FetchSuggestionsResponse = { reqnum: number; suggestions: SuggestionType[]; - highlightterm?: string; }; // wshrpc.FileCopyOpts @@ -468,6 +467,7 @@ declare global { presets: {[key: string]: MetaType}; termthemes: {[key: string]: TermThemeType}; connections: {[key: string]: ConnKeywords}; + bookmarks: {[key: string]: WebBookmark}; configerrors: ConfigError[]; }; @@ -581,6 +581,7 @@ declare global { "term:conndebug"?: string; "web:zoom"?: number; "web:hidenav"?: boolean; + "web:partition"?: string; "markdown:fontsize"?: number; "markdown:fixedfontsize"?: number; "vdom:*"?: boolean; @@ -779,13 +780,18 @@ declare global { type SuggestionType = { type: string; suggestionid: string; + display: string; + subtext?: string; icon?: string; iconcolor?: string; + iconsrc?: string; + matchpos?: number[]; + submatchpos?: number[]; + score?: number; "file:mimetype"?: string; - "file:name"?: string; "file:path"?: string; - matchpositions?: number[]; - score?: number; + "file:name"?: string; + "url:url"?: string; }; // telemetrydata.TEvent @@ -1281,6 +1287,16 @@ declare global { lastfocusts: number; }; + // wconfig.WebBookmark + type WebBookmark = { + url: string; + title?: string; + icon?: string; + iconcolor?: string; + iconurl?: string; + "display:order"?: number; + }; + // service.WebCallType type WebCallType = { service: string; diff --git a/go.mod b/go.mod index ba0d7c2b91..504a8284d0 100644 --- a/go.mod +++ b/go.mod @@ -94,8 +94,8 @@ require ( go.uber.org/atomic v1.7.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect diff --git a/go.sum b/go.sum index 6d0c813ff6..3386d44c68 100644 --- a/go.sum +++ b/go.sum @@ -206,8 +206,8 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -220,8 +220,8 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/faviconcache/faviconcache.go b/pkg/faviconcache/faviconcache.go new file mode 100644 index 0000000000..3fa12dbee8 --- /dev/null +++ b/pkg/faviconcache/faviconcache.go @@ -0,0 +1,196 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package faviconcache + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/panichandler" +) + +// --- Constants and Types --- + +// cacheDuration is how long a cached entry is considered “fresh.” +const cacheDuration = 24 * time.Hour + +// maxIconSize limits the favicon size to 256 KB. +const maxIconSize = 256 * 1024 // in bytes + +// FaviconCacheItem represents one cached favicon entry. +type FaviconCacheItem struct { + // Data is the base64-encoded data URL string (e.g. "data:image/png;base64,...") + Data string + // LastFetched is when this entry was last updated. + LastFetched time.Time +} + +// --- Global variables for managing in-flight fetches --- +// We use a mutex and a simple map to prevent multiple simultaneous fetches for the same domain. +var ( + fetchLock sync.Mutex + fetching = make(map[string]bool) +) + +// Use a semaphore (buffered channel) to limit concurrent fetches to 5. +var fetchSemaphore = make(chan bool, 5) + +var ( + faviconCacheLock sync.Mutex + faviconCache = make(map[string]*FaviconCacheItem) +) + +// --- GetFavicon --- +// +// GetFavicon takes a URL string and returns a base64-encoded src URL for an +// tag. If the favicon is already in cache and “fresh,” it returns it immediately. +// Otherwise it kicks off a background fetch (if one isn’t already in progress) +// and returns whatever is in the cache (which may be empty). +func GetFavicon(urlStr string) string { + // Parse the URL and extract the domain. + parsedURL, err := url.Parse(urlStr) + if err != nil { + log.Printf("GetFavicon: invalid URL %q: %v", urlStr, err) + return "" + } + domain := parsedURL.Hostname() + if domain == "" { + log.Printf("GetFavicon: no hostname found in URL %q", urlStr) + return "" + } + + // Try to get from our cache. + item, found := GetFromCache(domain) + if found { + // If the cached entry is not stale, return it. + if time.Since(item.LastFetched) < cacheDuration { + return item.Data + } + } + + // Either the item was not found or it’s stale: + // Launch an async fetch if one isn’t already running for this domain. + triggerAsyncFetch(domain) + + // Return the cached value (even if stale or empty). + return item.Data +} + +// triggerAsyncFetch starts a goroutine to update the favicon cache +// for the given domain if one isn’t already in progress. +func triggerAsyncFetch(domain string) { + fetchLock.Lock() + if fetching[domain] { + // Already fetching this domain; nothing to do. + fetchLock.Unlock() + return + } + // Mark this domain as in-flight. + fetching[domain] = true + fetchLock.Unlock() + + go func() { + defer func() { + panichandler.PanicHandler("Favicon:triggerAsyncFetch", recover()) + }() + + // Acquire a slot in the semaphore. + fetchSemaphore <- true + + // When done, ensure that we clear the “fetching” flag. + defer func() { + <-fetchSemaphore + fetchLock.Lock() + delete(fetching, domain) + fetchLock.Unlock() + }() + + iconStr, err := fetchFavicon(domain) + if err != nil { + log.Printf("triggerAsyncFetch: error fetching favicon for %s: %v", domain, err) + } + SetInCache(domain, FaviconCacheItem{Data: iconStr, LastFetched: time.Now()}) + }() +} + +func fetchFavicon(domain string) (string, error) { + // Create a context that times out after 5 seconds. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Special case for github.com - use their dark favicon from assets domain + url := "https://" + domain + "/favicon.ico" + if domain == "github.com" { + url = "https://github.githubassets.com/favicons/favicon-dark.png" + } + + // Create a new HTTP request with the context. + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", fmt.Errorf("error creating request for %s: %w", url, err) + } + + // Execute the HTTP request. + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("error fetching favicon from %s: %w", url, err) + } + defer resp.Body.Close() + + // Ensure we got a 200 OK. + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("non-OK HTTP status: %d fetching %s", resp.StatusCode, url) + } + + // Read the favicon bytes. + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading favicon data from %s: %w", url, err) + } + + // Encode the image bytes to base64. + b64Data := base64.StdEncoding.EncodeToString(data) + if len(b64Data) > maxIconSize { + return "", fmt.Errorf("favicon too large: %d bytes", len(b64Data)) + } + + // Try to detect MIME type from Content-Type header first + mimeType := resp.Header.Get("Content-Type") + if mimeType == "" { + // If no Content-Type header, detect from content + mimeType = http.DetectContentType(data) + } + + if !strings.HasPrefix(mimeType, "image/") { + return "", fmt.Errorf("unexpected MIME type: %s", mimeType) + } + + return "data:" + mimeType + ";base64," + b64Data, nil +} + +// TODO store in blockstore + +func GetFromCache(key string) (FaviconCacheItem, bool) { + faviconCacheLock.Lock() + defer faviconCacheLock.Unlock() + item, found := faviconCache[key] + if !found { + return FaviconCacheItem{}, false + } + return *item, true +} + +func SetInCache(key string, item FaviconCacheItem) { + faviconCacheLock.Lock() + defer faviconCacheLock.Unlock() + faviconCache[key] = &item +} diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index 0af5235e6d..133ffc1da8 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io/fs" + "net/url" "os" "path/filepath" "sort" @@ -14,12 +15,16 @@ import ( "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/util" + "github.com/wavetermdev/waveterm/pkg/faviconcache" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) +const MaxSuggestions = 50 + type MockDirEntry struct { NameStr string IsDirVal bool @@ -126,8 +131,199 @@ func resolveFileQuery(cwd string, query string) (string, string, string, error) return cwd, "", query, nil } -// FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching. func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { + if data.SuggestionType == "file" { + return fetchFileSuggestions(ctx, data) + } + if data.SuggestionType == "bookmark" { + return fetchBookmarkSuggestions(ctx, data) + } + return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) +} + +func filterBookmarksForValid(bookmarks map[string]wconfig.WebBookmark) map[string]wconfig.WebBookmark { + validBookmarks := make(map[string]wconfig.WebBookmark) + for k, v := range bookmarks { + if v.Url == "" { + continue + } + u, err := url.ParseRequestURI(v.Url) + if err != nil || u.Scheme == "" || u.Host == "" { + continue + } + + validBookmarks[k] = v + } + return validBookmarks +} + +func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { + if data.SuggestionType != "bookmark" { + return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) + } + + // scoredEntry holds a bookmark along with its computed score, the match positions for the + // field that will be used for display, the positions for the secondary field (if any), + // and its original index in the Bookmarks list. + type scoredEntry struct { + bookmark wconfig.WebBookmark + score int + matchPos []int // positions for the field that's used as Display + subMatchPos []int // positions for the other field (if any) + origIndex int + } + + bookmarks := wconfig.GetWatcher().GetFullConfig().Bookmarks + bookmarks = filterBookmarksForValid(bookmarks) + + searchTerm := data.Query + var patternRunes []rune + if searchTerm != "" { + patternRunes = []rune(strings.ToLower(searchTerm)) + } + + var scoredEntries []scoredEntry + var slab util.Slab + + bookmarkKeys := utilfn.GetMapKeys(bookmarks) + // sort by display:order and then by key + sort.Slice(bookmarkKeys, func(i, j int) bool { + bookmarkA := bookmarks[bookmarkKeys[i]] + bookmarkB := bookmarks[bookmarkKeys[j]] + if bookmarkA.DisplayOrder != bookmarkB.DisplayOrder { + return bookmarkA.DisplayOrder < bookmarkB.DisplayOrder + } + return bookmarkKeys[i] < bookmarkKeys[j] + }) + for i, bmkey := range bookmarkKeys { + bookmark := bookmarks[bmkey] + // If no search term, include all bookmarks (score 0, no positions). + if searchTerm == "" { + scoredEntries = append(scoredEntries, scoredEntry{ + bookmark: bookmark, + score: 0, + origIndex: i, + }) + continue + } + + // For bookmarks with a title, Display is set to the title and SubText to the URL. + // We perform fuzzy matching on both fields. + if bookmark.Title != "" { + // Fuzzy match against the title. + candidateTitle := strings.ToLower(bookmark.Title) + textTitle := util.ToChars([]byte(candidateTitle)) + resultTitle, titlePositionsPtr := algo.FuzzyMatchV2(false, true, true, &textTitle, patternRunes, true, &slab) + var titleScore int + var titlePositions []int + if titlePositionsPtr != nil { + titlePositions = *titlePositionsPtr + } + titleScore = resultTitle.Score + + // Fuzzy match against the URL. + candidateUrl := strings.ToLower(bookmark.Url) + textUrl := util.ToChars([]byte(candidateUrl)) + resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab) + var urlScore int + var urlPositions []int + if urlPositionsPtr != nil { + urlPositions = *urlPositionsPtr + } + urlScore = resultUrl.Score + + // Compute the overall score as the higher of the two. + maxScore := titleScore + if urlScore > maxScore { + maxScore = urlScore + } + + // If neither field produced a positive match, skip this bookmark. + if maxScore <= 0 { + continue + } + + // Since Display is title, we use the title match positions as MatchPos and the URL match positions as SubMatchPos. + scoredEntries = append(scoredEntries, scoredEntry{ + bookmark: bookmark, + score: maxScore, + matchPos: titlePositions, + subMatchPos: urlPositions, + origIndex: i, + }) + } else { + // For bookmarks with no title, Display is set to the URL. + // Only perform fuzzy matching against the URL. + candidateUrl := strings.ToLower(bookmark.Url) + textUrl := util.ToChars([]byte(candidateUrl)) + resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab) + urlScore := resultUrl.Score + var urlPositions []int + if urlPositionsPtr != nil { + urlPositions = *urlPositionsPtr + } + + // Skip this bookmark if the URL doesn't match. + if urlScore <= 0 { + continue + } + + scoredEntries = append(scoredEntries, scoredEntry{ + bookmark: bookmark, + score: urlScore, + matchPos: urlPositions, // match positions come from the URL, since that's what is displayed. + subMatchPos: nil, + origIndex: i, + }) + } + } + + // Sort the scored entries in descending order by score. + // For equal scores, preserve the original order from the Bookmarks list. + sort.Slice(scoredEntries, func(i, j int) bool { + if scoredEntries[i].score != scoredEntries[j].score { + return scoredEntries[i].score > scoredEntries[j].score + } + return scoredEntries[i].origIndex < scoredEntries[j].origIndex + }) + + // Build up to MaxSuggestions suggestions. + var suggestions []wshrpc.SuggestionType + for _, entry := range scoredEntries { + var display, subText string + if entry.bookmark.Title != "" { + display = entry.bookmark.Title + subText = entry.bookmark.Url + } else { + display = entry.bookmark.Url + subText = "" + } + + suggestion := wshrpc.SuggestionType{ + Type: "url", + SuggestionId: utilfn.QuickHashString(entry.bookmark.Url), + Display: display, + SubText: subText, + MatchPos: entry.matchPos, // These positions correspond to the field in Display. + SubMatchPos: entry.subMatchPos, // For bookmarks with a title, this is the URL match positions. + Score: entry.score, + UrlUrl: entry.bookmark.Url, + } + suggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url) + suggestions = append(suggestions, suggestion) + if len(suggestions) >= MaxSuggestions { + break + } + } + + return &wshrpc.FetchSuggestionsResponse{ + Suggestions: suggestions, + ReqNum: data.ReqNum, + }, nil +} + +// FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching. +func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { // Only support file suggestions. if data.SuggestionType != "file" { return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) @@ -222,12 +418,9 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w }) } - // Build up to 50 suggestions. + // Build up to MaxSuggestions suggestions var suggestions []wshrpc.SuggestionType - for i, candidate := range scoredEntries { - if i >= 50 { - break - } + for _, candidate := range scoredEntries { fileName := candidate.ent.Name() fullPath := filepath.Join(baseDir, fileName) suggestionFileName := filepath.Join(queryPrefix, fileName) @@ -242,18 +435,20 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w Type: "file", FilePath: fullPath, SuggestionId: utilfn.QuickHashString(fullPath), - // Use the queryPrefix to build the display name. - FileName: suggestionFileName, - FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), - MatchPositions: scoredEntries[i].positions, - Score: candidate.score, + Display: suggestionFileName, + FileName: suggestionFileName, + FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), + MatchPos: candidate.positions, + Score: candidate.score, } suggestions = append(suggestions, s) + if len(suggestions) >= MaxSuggestions { + break + } } return &wshrpc.FetchSuggestionsResponse{ - Suggestions: suggestions, - ReqNum: data.ReqNum, - HighlightTerm: searchTerm, + Suggestions: suggestions, + ReqNum: data.ReqNum, }, nil } diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index c6fec36d57..a3365eb8b1 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -106,6 +106,7 @@ const ( MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" + MetaKey_WebPartition = "web:partition" MetaKey_MarkdownFontSize = "markdown:fontsize" MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 6705aa4fd8..d583cc90c9 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -107,8 +107,9 @@ type MetaTSType struct { TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug - WebZoom float64 `json:"web:zoom,omitempty"` - WebHideNav *bool `json:"web:hidenav,omitempty"` + WebZoom float64 `json:"web:zoom,omitempty"` + WebHideNav *bool `json:"web:hidenav,omitempty"` + WebPartition string `json:"web:partition,omitempty"` MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"` MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"` diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index bb85899188..a1829d233c 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -125,6 +125,15 @@ type ConfigError struct { Err string `json:"err"` } +type WebBookmark struct { + Url string `json:"url"` + Title string `json:"title,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"iconcolor,omitempty"` + IconUrl string `json:"iconurl,omitempty"` + DisplayOrder float64 `json:"display:order,omitempty"` +} + type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` @@ -133,6 +142,7 @@ type FullConfigType struct { Presets map[string]waveobj.MetaMapType `json:"presets"` TermThemes map[string]TermThemeType `json:"termthemes"` Connections map[string]ConnKeywords `json:"connections"` + Bookmarks map[string]WebBookmark `json:"bookmarks"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` } type ConnKeywords struct { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a9c5137d8b..2a74a9c63e 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -733,19 +733,23 @@ type FetchSuggestionsData struct { } type FetchSuggestionsResponse struct { - ReqNum int `json:"reqnum"` - Suggestions []SuggestionType `json:"suggestions"` - HighlightTerm string `json:"highlightterm,omitempty"` + ReqNum int `json:"reqnum"` + Suggestions []SuggestionType `json:"suggestions"` } type SuggestionType struct { - Type string `json:"type"` - SuggestionId string `json:"suggestionid"` - Icon string `json:"icon,omitempty"` - IconColor string `json:"iconcolor,omitempty"` - FileMimeType string `json:"file:mimetype,omitempty"` - FileName string `json:"file:name,omitempty"` - FilePath string `json:"file:path,omitempty"` - MatchPositions []int `json:"matchpositions,omitempty"` - Score int `json:"score,omitempty"` + Type string `json:"type"` + SuggestionId string `json:"suggestionid"` + Display string `json:"display"` + SubText string `json:"subtext,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"iconcolor,omitempty"` + IconSrc string `json:"iconsrc,omitempty"` + MatchPos []int `json:"matchpos,omitempty"` + SubMatchPos []int `json:"submatchpos,omitempty"` + Score int `json:"score,omitempty"` + FileMimeType string `json:"file:mimetype,omitempty"` + FilePath string `json:"file:path,omitempty"` + FileName string `json:"file:name,omitempty"` + UrlUrl string `json:"url:url,omitempty"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index c8e9d95bb1..45426cbdd2 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -26,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare" + "github.com/wavetermdev/waveterm/pkg/suggestion" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" @@ -861,3 +862,7 @@ func (ws *WshServer) PathCommand(ctx context.Context, data wshrpc.PathCommandDat } return path, nil } + +func (ws *WshServer) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { + return suggestion.FetchSuggestions(ctx, data) +}