web bookmarks (#1930)

This commit is contained in:
Mike Sawka 2025-02-07 16:11:40 -08:00 committed by GitHub
parent 3e0712c55e
commit a73381296d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 900 additions and 312 deletions

View file

@ -88,7 +88,7 @@ function getViewElem(
);
}
if (blockView === "web") {
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} />;
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} blockRef={blockRef} />;
}
if (blockView === "waveai") {
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;

View file

@ -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
>
<BlockMask nodeModel={nodeModel} />
{preview || viewModel == null ? null : (

View file

@ -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);
}

View file

@ -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<HTMLElement>;
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<SuggestionControlProps, "anchorRef" | "isOpen"> & {
blockRef: React.RefObject<HTMLElement>;
openAtom: Atom<boolean>;
};
const SuggestionControl: React.FC<SuggestionControlProps> = ({
anchorRef,
isOpen,
onClose,
onSelect,
fetchSuggestions,
className,
children,
}) => {
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
return <SuggestionControlInner {...{ anchorRef, onClose, onSelect, fetchSuggestions, className, children }} />;
};
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(
<span key={`h-${targetIndex}`} className="text-blue-500 font-bold">
{target[targetIndex]}
</span>
);
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 <img src={suggestion.iconsrc} alt="favicon" className="w-4 h-4 object-contain" />;
}
if (suggestion.icon) {
const iconClass = makeIconClass(suggestion.icon, true);
const iconColor = suggestion.iconcolor;
return <i className={iconClass} style={{ color: iconColor }} />;
}
if (suggestion.type === "url") {
const iconClass = makeIconClass("globe", true);
const iconColor = suggestion.iconcolor;
return <i className={iconClass} style={{ color: iconColor }} />;
} 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 <i className={iconClass} style={{ color: iconColor }} />;
}
const iconClass = makeIconClass("file", true);
return <i className={iconClass} />;
};
const SuggestionContent: React.FC<{
suggestion: SuggestionType;
}> = ({ suggestion }) => {
if (!isBlank(suggestion.subtext)) {
return (
<div className="flex flex-col">
{/* Title on the first line, with highlighting */}
<div className="truncate text-white">{highlightPositions(suggestion.display, suggestion.matchpos)}</div>
{/* Subtext on the second line in a smaller, grey style */}
<div className="truncate text-sm text-secondary">
{highlightPositions(suggestion.subtext, suggestion.submatchpos)}
</div>
</div>
);
}
return <span className="truncate">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>;
};
const BlockHeaderSuggestionControl: React.FC<BlockHeaderSuggestionControlProps> = (props) => {
const [headerElem, setHeaderElem] = useState<HTMLElement>(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 <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />;
};
/**
* 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 (
<div className="flex items-center justify-center min-h-[120px] p-4">
{children ?? <span className="text-gray-500">No Suggestions</span>}
</div>
);
};
const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
return (
<div className="flex items-center justify-center min-h-[120px] p-4">
{children ?? <span className="text-gray-500">No Suggestions</span>}
</div>
);
};
interface SuggestionControlInnerProps extends Omit<SuggestionControlProps, "isOpen"> {}
const SuggestionControlInner: React.FC<SuggestionControlInnerProps> = ({
anchorRef,
onClose,
onSelect,
onTab,
fetchSuggestions,
className,
placeholderText,
children,
}) => {
const widgetId = useId();
const [query, setQuery] = useState("");
const reqNumRef = useRef(0);
let [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [fetched, setFetched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div
className={clsx(
"w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
middlewareData?.offset == null ? "opacity-0" : null,
className
)}
ref={refs.setFloating}
style={floatingStyles}
>
<div className="p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
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}
/>
</div>
{fetched &&
(suggestions.length > 0 ? (
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
{suggestions.map((suggestion, index) => (
<div
key={suggestion.suggestionid}
className={clsx(
"flex items-center gap-3 px-4 py-2 cursor-pointer",
index === selectedIndex ? "bg-accentbg" : "hover:bg-hoverbg",
"text-gray-100"
)}
onClick={() => {
onSelect(suggestion, query);
onClose();
}}
>
<SuggestionIcon suggestion={suggestion} />
<SuggestionContent suggestion={suggestion} />
</div>
))}
</div>
) : (
// Render the empty state (either a provided child or the default)
<div key="empty" className="flex items-center justify-center min-h-[120px] p-4">
{query === ""
? (noDataChild ?? <SuggestionControlNoData />)
: (emptyStateChild ?? <SuggestionControlNoResults />)}
</div>
))}
</div>
);
};
export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults };

View file

@ -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<HTMLElement>;
isOpen: boolean;
onClose: () => void;
onSelect: (item: SuggestionType, queryStr: string) => void;
fetchSuggestions: SuggestionsFnType;
className?: string;
placeholderText?: string;
}
const Typeahead: React.FC<TypeaheadProps> = ({ anchorRef, isOpen, onClose, onSelect, fetchSuggestions, className }) => {
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
return <TypeaheadInner {...{ anchorRef, onClose, onSelect, fetchSuggestions, className }} />;
};
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) => <span className="text-blue-500 font-bold">{char}</span>);
}
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(<span className="text-blue-500 font-bold">{target[targetIndex]}</span>);
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 <i className={iconClass} style={{ color: iconColor }} />;
};
const TypeaheadInner: React.FC<Omit<TypeaheadProps, "isOpen">> = ({
anchorRef,
onClose,
onSelect,
fetchSuggestions,
className,
placeholderText,
}) => {
const widgetId = useId();
const [query, setQuery] = useState("");
const reqNumRef = useRef(0);
const [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [highlightTerm, setHighlightTerm] = useState("");
const [fetched, setFetched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div
className={clsx(
"w-96 rounded-lg bg-gray-800 shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
middlewareData?.offset == null ? "opacity-0" : null,
className
)}
ref={refs.setFloating}
style={floatingStyles}
>
<div className="p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
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}
/>
</div>
{fetched && suggestions.length > 0 && (
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
{suggestions.map((suggestion, index) => (
<div
key={suggestion.suggestionid}
className={clsx(
"flex items-center gap-3 px-4 py-2 cursor-pointer",
"hover:bg-gray-700",
index === selectedIndex ? "bg-gray-700" : "",
"text-gray-100"
)}
onClick={() => {
onSelect(suggestion, query);
onClose();
}}
>
<SuggestionIcon suggestion={suggestion} />
<span className="truncate">{getHighlightedText(suggestion, highlightTerm)}</span>
</div>
))}
</div>
)}
</div>
);
};
export { Typeahead };

View file

@ -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({
<SpecializedView parentRef={contentRef} model={model} />
</div>
</div>
<Typeahead
anchorRef={blockRef}
isOpen={openFileModal}
<BlockHeaderSuggestionControl
blockRef={blockRef}
openAtom={model.openFileModal}
onClose={() => model.updateOpenFileModalAndError(false)}
onSelect={handleSelect}
onTab={handleTab}
fetchSuggestions={fetchSuggestionsFn}
placeholderText="Open File..."
/>

View file

@ -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<boolean>;
hideNav: Atom<boolean>;
searchAtoms?: SearchAtoms;
typeaheadOpen: PrimitiveAtom<boolean>;
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.nodeModel = nodeModel;
@ -78,6 +84,7 @@ export class WebViewModel implements ViewModel {
this.webviewRef = createRef<WebviewTag>();
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<FetchSuggestionsResponse> {
const result = await RpcApi.FetchSuggestionsCommand(TabRpcClient, {
suggestiontype: "bookmark",
query,
widgetid: reqContext.widgetid,
reqnum: reqContext.reqnum,
});
return result;
}
handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, 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<HTMLDivElement>;
}
const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
const BookmarkTypeahead = memo(
({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject<HTMLDivElement> }) => {
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 (
<BlockHeaderSuggestionControl
blockRef={blockRef}
openAtom={model.typeaheadOpen}
onClose={() => 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..."
>
<SuggestionControlNoData>
<div className="text-center">
<p className="text-lg font-bold text-gray-100">No Bookmarks Configured</p>
<p className="text-sm text-gray-400 mt-1">
Edit your <code className="font-mono">bookmarks.json</code> file to configure bookmarks.
</p>
<button
onClick={openBookmarksJson}
className="mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer"
>
Open bookmarks.json
</button>
</div>
</SuggestionControlNoData>
<SuggestionControlNoResults>
<div className="text-center">
<p className="text-sm text-gray-400">No matching bookmarks</p>
<button
onClick={openBookmarksJson}
className="mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer"
>
Edit bookmarks.json
</button>
</div>
</SuggestionControlNoResults>
</BlockHeaderSuggestionControl>
);
}
);
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 && (
<div className="webview-error">
@ -796,6 +888,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
</div>
)}
<Search {...searchProps} />
<BookmarkTypeahead model={model} blockRef={blockRef} />
</Fragment>
);
});

View file

@ -80,7 +80,7 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => {
return (
<div
className={clsx(
"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-highlightbg hover:text-white cursor-pointer",
"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer",
widget["display:hidden"] && "hidden"
)}
onClick={() => handleWidgetSelect(widget)}

View file

@ -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;

View file

@ -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;
"file:mimetype"?: string;
"file:name"?: string;
"file:path"?: string;
matchpositions?: number[];
iconsrc?: string;
matchpos?: number[];
submatchpos?: number[];
score?: number;
"file:mimetype"?: string;
"file:path"?: string;
"file:name"?: string;
"url:url"?: string;
};
// telemetrydata.TEvent
@ -1291,6 +1297,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;

4
go.mod
View file

@ -95,8 +95,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-20250127172529-29210b9bc287 // indirect

8
go.sum
View file

@ -208,8 +208,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=
@ -222,8 +222,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=

View file

@ -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 <img>
// tag. If the favicon is already in cache and “fresh,” it returns it immediately.
// Otherwise it kicks off a background fetch (if one isnt 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 its stale:
// Launch an async fetch if one isnt 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 isnt 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
}

View file

@ -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/fzfs 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/fzfs 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
}

View file

@ -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"

View file

@ -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"`

View file

@ -129,6 +129,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"`
@ -137,6 +146,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 {

View file

@ -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"`
}

View file

@ -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)
}