mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
web bookmarks (#1930)
This commit is contained in:
parent
3e0712c55e
commit
a73381296d
19 changed files with 900 additions and 312 deletions
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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 : (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
317
frontend/app/suggestion/suggestion.tsx
Normal file
317
frontend/app/suggestion/suggestion.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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..."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
26
frontend/types/gotypes.d.ts
vendored
26
frontend/types/gotypes.d.ts
vendored
|
|
@ -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
4
go.mod
|
|
@ -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
8
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
196
pkg/faviconcache/faviconcache.go
Normal file
196
pkg/faviconcache/faviconcache.go
Normal 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 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue