From eeee2b3a9ad192627671af3726d8043e20772e34 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 6 Feb 2025 20:27:03 -0800 Subject: [PATCH 01/16] more generic typeahead, display/subtext... hook up URLs... create an onTab handler --- frontend/app/block/block.tsx | 2 +- frontend/app/block/blockframe.tsx | 2 +- frontend/app/typeahead/typeahead.tsx | 80 +++++++++++++++++---------- frontend/app/view/preview/preview.tsx | 21 ++++++- frontend/app/view/webview/webview.tsx | 75 ++++++++++++++++++++++++- frontend/types/gotypes.d.ts | 10 +++- pkg/suggestion/suggestion.go | 8 +-- pkg/wshrpc/wshrpctypes.go | 10 +++- 8 files changed, 165 insertions(+), 43 deletions(-) diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 698f640429..a5bb77b20a 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -88,7 +88,7 @@ function getViewElem( ); } if (blockView === "web") { - return ; + return ; } if (blockView === "waveai") { return ; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index f71ce7cfae..33c33c1263 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -607,7 +607,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { "--magnified-block-blur": `${magnifiedBlockBlur}px`, } as React.CSSProperties } - inert={preview ? "1" : undefined} + inert={preview ? "1" : undefined} // this does exist in the DOM, just not in react > {preview || viewModel == null ? null : ( diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/typeahead/typeahead.tsx index cb4fa93ca3..24c54a6107 100644 --- a/frontend/app/typeahead/typeahead.tsx +++ b/frontend/app/typeahead/typeahead.tsx @@ -13,6 +13,7 @@ interface TypeaheadProps { isOpen: boolean; onClose: () => void; onSelect: (item: SuggestionType, queryStr: string) => void; + onTab?: (item: SuggestionType, queryStr: string) => string; fetchSuggestions: SuggestionsFnType; className?: string; placeholderText?: string; @@ -76,12 +77,12 @@ function highlightPositions(target: string, positions: number[]): ReactNode[] { function getHighlightedText(suggestion: SuggestionType, highlightTerm: string): ReactNode[] { if (suggestion.matchpositions != null && suggestion.matchpositions.length > 0) { - return highlightPositions(suggestion["file:name"], suggestion.matchpositions); + return highlightPositions(suggestion.display, suggestion.matchpositions); } if (isBlank(highlightTerm)) { - return [suggestion["file:name"]]; + return [suggestion.display]; } - return defaultHighlighter(suggestion["file:name"], highlightTerm); + return defaultHighlighter(suggestion.display, highlightTerm); } function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] { @@ -100,23 +101,54 @@ function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): } 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.iconsrc) { + return favicon; } - if (suggestion.iconcolor != null) { - iconColor = suggestion.iconcolor; + if (suggestion.icon) { + const iconClass = makeIconClass(suggestion.icon, true); + const iconColor = suggestion.iconcolor; + return ; } - const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); - return ; + if (suggestion.type === "url") { + const iconClass = makeIconClass("globe", true); + const iconColor = suggestion.iconcolor; + return ; + } else if (suggestion.type === "file") { + // For file suggestions, use the existing logic. + const fullConfig = useAtomValue(atoms.fullConfigAtom); + let icon: string = null; + let iconColor: string = null; + if (icon == null && suggestion["file:mimetype"] != null) { + [icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]); + } + const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); + return ; + } + return makeIconClass("file", true); +}; + +const SuggestionContent: React.FC<{ + suggestion: SuggestionType; + highlightTerm: string; +}> = ({ suggestion, highlightTerm }) => { + if (!isBlank(suggestion.subtext)) { + return ( +
+ {/* Title on the first line, with highlighting */} +
{getHighlightedText(suggestion, highlightTerm)}
+ {/* Subtext on the second line in a smaller, grey style */} +
{suggestion.subtext}
+
+ ); + } + return {getHighlightedText(suggestion, highlightTerm)}; }; const TypeaheadInner: React.FC> = ({ anchorRef, onClose, onSelect, + onTab, fetchSuggestions, className, placeholderText, @@ -137,12 +169,7 @@ const TypeaheadInner: React.FC> = ({ }); useEffect(() => { - if (anchorRef.current == null) { - refs.setReference(null); - return; - } - const headerElem = anchorRef.current.querySelector("[data-role='block-header']"); - refs.setReference(headerElem); + refs.setReference(anchorRef.current); }, [anchorRef.current]); useEffect(() => { @@ -196,11 +223,9 @@ const TypeaheadInner: React.FC> = ({ 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"]); + const tabResult = onTab?.(suggestion, query); + if (tabResult != null) { + setQuery(tabResult); } } } @@ -226,9 +251,7 @@ const TypeaheadInner: React.FC> = ({ 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" + 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} /> @@ -238,8 +261,7 @@ const TypeaheadInner: React.FC> = ({
> = ({ }} > - {getHighlightedText(suggestion, highlightTerm)} +
))} diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index f2c9bd7ddc..a078a81f16 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -1125,12 +1125,30 @@ function PreviewView({ }) { const connStatus = useAtomValue(model.connStatus); const openFileModal = useAtomValue(model.openFileModal); + const [headerElem, setHeaderElem] = useState(null); + + useEffect(() => { + if (blockRef.current == null) { + setHeaderElem(null); + return; + } + const headerElem = blockRef.current.querySelector("[data-role='block-header']"); + setHeaderElem(headerElem as HTMLElement); + }, [blockRef.current]); + 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); }; @@ -1143,10 +1161,11 @@ function PreviewView({ model.updateOpenFileModalAndError(false)} onSelect={handleSelect} + onTab={handleTab} fetchSuggestions={fetchSuggestionsFn} placeholderText="Open File..." /> diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index c3ca0d0104..4d82e70bcd 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -8,6 +8,7 @@ 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 { Typeahead } from "@/app/typeahead/typeahead"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; @@ -54,6 +55,7 @@ export class WebViewModel implements ViewModel { domReady: PrimitiveAtom; hideNav: Atom; searchAtoms?: SearchAtoms; + typeaheadOpen: PrimitiveAtom; constructor(blockId: string, nodeModel: BlockNodeModel) { this.nodeModel = nodeModel; @@ -78,6 +80,7 @@ export class WebViewModel implements ViewModel { this.webviewRef = createRef(); this.domReady = atom(false); this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav"); + this.typeaheadOpen = atom(false); this.mediaPlaying = atom(false); this.mediaMuted = atom(false); @@ -229,6 +232,38 @@ export class WebViewModel implements ViewModel { } } + setTypeaheadOpen(open: boolean) { + globalStore.set(this.typeaheadOpen, open); + } + + async fetchBookmarkSuggestions( + query: string, + reqContext: SuggestionRequestContext + ): Promise { + let suggestions: SuggestionType[] = []; + suggestions.push({ + type: "url", + suggestionid: "google", + display: "Google", + subtext: "https://www.google.com", + "url:url": "https://www.google.com", + }); + suggestions.push({ + type: "url", + suggestionid: "claude", + display: "Claude AI", + subtext: "https://claude.ai", + "url:url": "https://claude.ai", + }); + suggestions.push({ + type: "url", + suggestionid: "chatgpt", + display: "https://chatgpt.com", + "url:url": "https://chatgpt.com/", + }); + return { suggestions: suggestions, reqnum: reqContext.reqnum }; + } + handleUrlWrapperMouseOver(e: React.MouseEvent) { const urlInputFocused = globalStore.get(this.urlInputFocused); if (e.type === "mouseover" && !urlInputFocused) { @@ -456,6 +491,10 @@ 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 false; } @@ -570,9 +609,42 @@ interface WebViewProps { blockId: string; model: WebViewModel; onFailLoad?: (url: string) => void; + blockRef: React.RefObject; } -const WebView = memo(({ model, onFailLoad }: WebViewProps) => { +const BookmarkTypeahead = memo( + ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + const typeaheadOpen = useAtomValue(model.typeaheadOpen); + const [headerElem, setHeaderElem] = useState(null); + + useEffect(() => { + if (blockRef.current == null) { + setHeaderElem(null); + return; + } + const headerElem = blockRef.current.querySelector("[data-role='block-header']"); + setHeaderElem(headerElem as HTMLElement); + }, [blockRef.current]); + + return ( + model.setTypeaheadOpen(false)} + onSelect={(suggestion) => { + if (suggestion == null || suggestion.type != "url") { + return; + } + model.loadUrl(suggestion["url:url"], "bookmark-typeahead"); + }} + fetchSuggestions={model.fetchBookmarkSuggestions} + placeholderText="Open Bookmark..." + /> + ); + } +); + +const WebView = memo(({ model, onFailLoad, blockRef }: WebViewProps) => { const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); @@ -796,6 +868,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => { )} + ); }); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index eacb9e4724..3de41944d2 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -779,13 +779,17 @@ 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; + iconsrc?: string; matchpositions?: number[]; score?: number; + "file:mimetype"?: string; + "file:path"?: string; + "file:name"?: string; + "url:url"?: string; }; // telemetrydata.TEvent diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index 0af5235e6d..a2a344e124 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -239,10 +239,10 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w } } s := wshrpc.SuggestionType{ - Type: "file", - FilePath: fullPath, - SuggestionId: utilfn.QuickHashString(fullPath), - // Use the queryPrefix to build the display name. + Type: "file", + FilePath: fullPath, + SuggestionId: utilfn.QuickHashString(fullPath), + Display: suggestionFileName, FileName: suggestionFileName, FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), MatchPositions: scoredEntries[i].positions, diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a9c5137d8b..af34955a28 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -741,11 +741,15 @@ type FetchSuggestionsResponse struct { type SuggestionType struct { 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"` - FileMimeType string `json:"file:mimetype,omitempty"` - FileName string `json:"file:name,omitempty"` - FilePath string `json:"file:path,omitempty"` + IconSrc string `json:"iconsrc,omitempty"` MatchPositions []int `json:"matchpositions,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"` } From 50f156d3100cc97a9f91dbc80adc3239343104a5 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 6 Feb 2025 21:49:26 -0800 Subject: [PATCH 02/16] get bookmark suggestions working --- frontend/app/typeahead/typeahead.tsx | 69 ++------ frontend/app/view/webview/webview.tsx | 27 +--- frontend/types/gotypes.d.ts | 4 +- pkg/suggestion/suggestion.go | 217 ++++++++++++++++++++++++-- pkg/wshrpc/wshrpctypes.go | 32 ++-- pkg/wshrpc/wshserver/wshserver.go | 5 + 6 files changed, 251 insertions(+), 103 deletions(-) diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/typeahead/typeahead.tsx index 24c54a6107..1b7cd52152 100644 --- a/frontend/app/typeahead/typeahead.tsx +++ b/frontend/app/typeahead/typeahead.tsx @@ -25,40 +25,13 @@ const Typeahead: React.FC = ({ anchorRef, isOpen, onClose, onSel return ; }; -function highlightSearchMatch(target: string, search: string, highlightFn: (char: string) => ReactNode): ReactNode[] { - if (!search || !target) return [target]; - - const result: ReactNode[] = []; - let targetIndex = 0; - let searchIndex = 0; - - while (targetIndex < target.length) { - // If we've matched all search chars, add remaining target string - if (searchIndex >= search.length) { - result.push(target.slice(targetIndex)); - break; - } - - // If current chars match - if (target[targetIndex].toLowerCase() === search[searchIndex].toLowerCase()) { - // Add highlighted character - result.push(highlightFn(target[targetIndex])); - searchIndex++; - targetIndex++; - } else { - // Add non-matching character - result.push(target[targetIndex]); - targetIndex++; - } - } - return result; -} - -function defaultHighlighter(target: string, search: string): ReactNode[] { - return highlightSearchMatch(target, search, (char) => {char}); -} - function highlightPositions(target: string, positions: number[]): ReactNode[] { + if (target == null) { + return []; + } + if (positions == null) { + return [target]; + } const result: ReactNode[] = []; let targetIndex = 0; let posIndex = 0; @@ -75,16 +48,6 @@ function highlightPositions(target: string, positions: number[]): ReactNode[] { return result; } -function getHighlightedText(suggestion: SuggestionType, highlightTerm: string): ReactNode[] { - if (suggestion.matchpositions != null && suggestion.matchpositions.length > 0) { - return highlightPositions(suggestion.display, suggestion.matchpositions); - } - if (isBlank(highlightTerm)) { - return [suggestion.display]; - } - return defaultHighlighter(suggestion.display, highlightTerm); -} - function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] { if (mimeType == null) { return [null, null]; @@ -124,24 +87,26 @@ const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion } const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); return ; } - return makeIconClass("file", true); + const iconClass = makeIconClass("file", true); + return ; }; const SuggestionContent: React.FC<{ suggestion: SuggestionType; - highlightTerm: string; -}> = ({ suggestion, highlightTerm }) => { +}> = ({ suggestion }) => { if (!isBlank(suggestion.subtext)) { return (
{/* Title on the first line, with highlighting */} -
{getHighlightedText(suggestion, highlightTerm)}
+
{highlightPositions(suggestion.display, suggestion.matchpos)}
{/* Subtext on the second line in a smaller, grey style */} -
{suggestion.subtext}
+
+ {highlightPositions(suggestion.subtext, suggestion.submatchpos)} +
); } - return {getHighlightedText(suggestion, highlightTerm)}; + return {highlightPositions(suggestion.display, suggestion.matchpos)}; }; const TypeaheadInner: React.FC> = ({ @@ -158,7 +123,6 @@ const TypeaheadInner: React.FC> = ({ const reqNumRef = useRef(0); const [suggestions, setSuggestions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); - const [highlightTerm, setHighlightTerm] = useState(""); const [fetched, setFetched] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(null); @@ -179,7 +143,6 @@ const TypeaheadInner: React.FC> = ({ return; } setSuggestions(results.suggestions ?? []); - setHighlightTerm(results.highlightterm ?? ""); setFetched(true); }); }, [query, fetchSuggestions]); @@ -231,6 +194,8 @@ const TypeaheadInner: React.FC> = ({ } }; + console.log("TypeaheadInner", suggestions); + return (
> = ({ }} > - +
))} diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 4d82e70bcd..0bf05bfad7 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -240,28 +240,13 @@ export class WebViewModel implements ViewModel { query: string, reqContext: SuggestionRequestContext ): Promise { - let suggestions: SuggestionType[] = []; - suggestions.push({ - type: "url", - suggestionid: "google", - display: "Google", - subtext: "https://www.google.com", - "url:url": "https://www.google.com", + const result = await RpcApi.FetchSuggestionsCommand(TabRpcClient, { + suggestiontype: "bookmark", + query, + widgetid: reqContext.widgetid, + reqnum: reqContext.reqnum, }); - suggestions.push({ - type: "url", - suggestionid: "claude", - display: "Claude AI", - subtext: "https://claude.ai", - "url:url": "https://claude.ai", - }); - suggestions.push({ - type: "url", - suggestionid: "chatgpt", - display: "https://chatgpt.com", - "url:url": "https://chatgpt.com/", - }); - return { suggestions: suggestions, reqnum: reqContext.reqnum }; + return result; } handleUrlWrapperMouseOver(e: React.MouseEvent) { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 3de41944d2..4a3397fd4a 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -387,7 +387,6 @@ declare global { type FetchSuggestionsResponse = { reqnum: number; suggestions: SuggestionType[]; - highlightterm?: string; }; // wshrpc.FileCopyOpts @@ -784,7 +783,8 @@ declare global { icon?: string; iconcolor?: string; iconsrc?: string; - matchpositions?: number[]; + matchpos?: number[]; + submatchpos?: number[]; score?: number; "file:mimetype"?: string; "file:path"?: string; diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index a2a344e124..d139e974e8 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -126,8 +126,202 @@ func resolveFileQuery(cwd string, query string) (string, string, string, error) return cwd, "", query, nil } -// FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching. +type BookmarkType struct { + FavIcon string `json:"favicon,omitempty"` + Title string `json:"title,omitempty"` + Url string `json:"url"` +} + +var Bookmarks = []BookmarkType{ + { + Title: "Google", + Url: "https://www.google.com", + }, + { + Title: "Claude AI", + Url: "https://claude.ai", + }, + { + Title: "Wave Terminal", + Url: "https://waveterm.com", + }, + { + Title: "Wave Github", + Url: "https://github.com/wavetermdev/waveterm", + }, + { + Title: "Chat GPT (Open AI)", + Url: "https://chatgpt.com", + }, + { + Title: "Wave Pull Requests", + Url: "https://github.com/wavetermdev/waveterm/pulls", + }, +} + 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 fetchBookmarkSuggestions(ctx 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 BookmarkType + score int + matchPos []int // positions for the field that's used as Display + subMatchPos []int // positions for the other field (if any) + origIndex int + } + + searchTerm := data.Query + var patternRunes []rune + if searchTerm != "" { + patternRunes = []rune(strings.ToLower(searchTerm)) + } + + var scoredEntries []scoredEntry + var slab util.Slab + + for i, bookmark := range Bookmarks { + // 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 50 suggestions. + var suggestions []wshrpc.SuggestionType + for i, entry := range scoredEntries { + if i >= 50 { + break + } + + 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, + } + suggestions = append(suggestions, suggestion) + } + + return &wshrpc.FetchSuggestionsResponse{ + Suggestions: suggestions, + ReqNum: data.ReqNum, + }, nil +} + +// FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching. +func fetchFileSuggestions(ctx 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) @@ -239,21 +433,20 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w } } s := wshrpc.SuggestionType{ - Type: "file", - FilePath: fullPath, - SuggestionId: utilfn.QuickHashString(fullPath), - Display: suggestionFileName, - FileName: suggestionFileName, - FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), - MatchPositions: scoredEntries[i].positions, - Score: candidate.score, + Type: "file", + FilePath: fullPath, + SuggestionId: utilfn.QuickHashString(fullPath), + Display: suggestionFileName, + FileName: suggestionFileName, + FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), + MatchPos: scoredEntries[i].positions, + Score: candidate.score, } suggestions = append(suggestions, s) } return &wshrpc.FetchSuggestionsResponse{ - Suggestions: suggestions, - ReqNum: data.ReqNum, - HighlightTerm: searchTerm, + Suggestions: suggestions, + ReqNum: data.ReqNum, }, nil } diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index af34955a28..2a74a9c63e 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -733,23 +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"` - Display string `json:"display"` - SubText string `json:"subtext,omitempty"` - Icon string `json:"icon,omitempty"` - IconColor string `json:"iconcolor,omitempty"` - IconSrc string `json:"iconsrc,omitempty"` - MatchPositions []int `json:"matchpositions,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"` + Type string `json:"type"` + SuggestionId string `json:"suggestionid"` + Display string `json:"display"` + SubText string `json:"subtext,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"iconcolor,omitempty"` + IconSrc string `json:"iconsrc,omitempty"` + MatchPos []int `json:"matchpos,omitempty"` + SubMatchPos []int `json:"submatchpos,omitempty"` + Score int `json:"score,omitempty"` + FileMimeType string `json:"file:mimetype,omitempty"` + FilePath string `json:"file:path,omitempty"` + FileName string `json:"file:name,omitempty"` + UrlUrl string `json:"url:url,omitempty"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index c8e9d95bb1..45426cbdd2 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -26,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare" + "github.com/wavetermdev/waveterm/pkg/suggestion" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" @@ -861,3 +862,7 @@ func (ws *WshServer) PathCommand(ctx context.Context, data wshrpc.PathCommandDat } return path, nil } + +func (ws *WshServer) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { + return suggestion.FetchSuggestions(ctx, data) +} From fcc1e5481f170dbc6685c36670bade1c384a944c Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 11:06:49 -0800 Subject: [PATCH 03/16] update colors to match theme... also make blockheader typeahead more ergonomic by adding a wrapper class --- frontend/app/typeahead/typeahead.tsx | 40 +++++++++++++++++++++------ frontend/app/view/preview/preview.tsx | 20 +++----------- frontend/app/view/webview/webview.tsx | 20 +++----------- frontend/app/workspace/workspace.tsx | 2 +- frontend/tailwindsetup.css | 5 +++- 5 files changed, 44 insertions(+), 43 deletions(-) diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/typeahead/typeahead.tsx index 1b7cd52152..2af4ba6ed3 100644 --- a/frontend/app/typeahead/typeahead.tsx +++ b/frontend/app/typeahead/typeahead.tsx @@ -5,7 +5,7 @@ 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 { Atom, useAtomValue } from "jotai"; import React, { ReactNode, useEffect, useId, useRef, useState } from "react"; interface TypeaheadProps { @@ -19,6 +19,11 @@ interface TypeaheadProps { placeholderText?: string; } +type BlockHeaderTypeaheadProps = Omit & { + blockRef: React.RefObject; + openAtom: Atom; +}; + const Typeahead: React.FC = ({ anchorRef, isOpen, onClose, onSelect, fetchSuggestions, className }) => { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; @@ -98,9 +103,9 @@ const SuggestionContent: React.FC<{ return (
{/* Title on the first line, with highlighting */} -
{highlightPositions(suggestion.display, suggestion.matchpos)}
+
{highlightPositions(suggestion.display, suggestion.matchpos)}
{/* Subtext on the second line in a smaller, grey style */} -
+
{highlightPositions(suggestion.subtext, suggestion.submatchpos)}
@@ -109,6 +114,23 @@ const SuggestionContent: React.FC<{ return {highlightPositions(suggestion.display, suggestion.matchpos)}; }; +const BlockHeaderTypeahead: React.FC = (props) => { + const [headerElem, setHeaderElem] = useState(null); + const isOpen = useAtomValue(props.openAtom); + + useEffect(() => { + if (props.blockRef.current == null) { + setHeaderElem(null); + return; + } + const headerElem = props.blockRef.current.querySelector("[data-role='block-header']"); + setHeaderElem(headerElem as HTMLElement); + }, [props.blockRef.current]); + + const newClass = clsx(props.className, "rounded-t-none"); + return ; +}; + const TypeaheadInner: React.FC> = ({ anchorRef, onClose, @@ -129,7 +151,7 @@ const TypeaheadInner: React.FC> = ({ const { refs, floatingStyles, middlewareData } = useFloating({ placement: "bottom", strategy: "absolute", - middleware: [offset(5)], + middleware: [offset(-1)], }); useEffect(() => { @@ -199,7 +221,7 @@ const TypeaheadInner: React.FC> = ({ return (
> = ({ 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" + 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} />
@@ -226,8 +248,8 @@ const TypeaheadInner: React.FC> = ({
{ @@ -245,4 +267,4 @@ const TypeaheadInner: React.FC> = ({ ); }; -export { Typeahead }; +export { BlockHeaderTypeahead, Typeahead }; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index a078a81f16..8db761449e 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -9,7 +9,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { Typeahead } from "@/app/typeahead/typeahead"; +import { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; import { @@ -1124,18 +1124,6 @@ function PreviewView({ model: PreviewModel; }) { const connStatus = useAtomValue(model.connStatus); - const openFileModal = useAtomValue(model.openFileModal); - const [headerElem, setHeaderElem] = useState(null); - - useEffect(() => { - if (blockRef.current == null) { - setHeaderElem(null); - return; - } - const headerElem = blockRef.current.querySelector("[data-role='block-header']"); - setHeaderElem(headerElem as HTMLElement); - }, [blockRef.current]); - if (connStatus?.status != "connected") { return null; } @@ -1160,9 +1148,9 @@ function PreviewView({
- model.updateOpenFileModalAndError(false)} onSelect={handleSelect} onTab={handleTab} diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 0bf05bfad7..df6134222d 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -8,7 +8,7 @@ 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 { Typeahead } from "@/app/typeahead/typeahead"; +import { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; @@ -599,22 +599,10 @@ interface WebViewProps { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { - const typeaheadOpen = useAtomValue(model.typeaheadOpen); - const [headerElem, setHeaderElem] = useState(null); - - useEffect(() => { - if (blockRef.current == null) { - setHeaderElem(null); - return; - } - const headerElem = blockRef.current.querySelector("[data-role='block-header']"); - setHeaderElem(headerElem as HTMLElement); - }, [blockRef.current]); - return ( - model.setTypeaheadOpen(false)} onSelect={(suggestion) => { if (suggestion == null || suggestion.type != "url") { diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 5836277cfd..49cc02f040 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -80,7 +80,7 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => { return (
handleWidgetSelect(widget)} diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index b755802d77..64807f64d1 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -23,9 +23,12 @@ --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); --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; From 74b699e0032bd266886c5d1542062df066a08e22 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 11:08:40 -0800 Subject: [PATCH 04/16] isDev wrapper on bookmarks --- frontend/app/view/webview/webview.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index df6134222d..9a39aa2349 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -10,6 +10,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; import { WOS, globalStore } from "@/store/global"; +import { isDev } from "@/util/isdev"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; @@ -599,6 +600,9 @@ interface WebViewProps { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + if (!isDev) { + return null; + } return ( Date: Fri, 7 Feb 2025 14:17:42 -0800 Subject: [PATCH 05/16] special case for github favicon --- frontend/app/typeahead/typeahead.tsx | 2 +- frontend/app/view/webview/webview.tsx | 5 +- go.mod | 4 +- go.sum | 8 +- pkg/faviconcache/faviconcache.go | 180 ++++++++++++++++++++++++++ pkg/suggestion/suggestion.go | 17 +-- 6 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 pkg/faviconcache/faviconcache.go diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/typeahead/typeahead.tsx index 2af4ba6ed3..764c5b329d 100644 --- a/frontend/app/typeahead/typeahead.tsx +++ b/frontend/app/typeahead/typeahead.tsx @@ -70,7 +70,7 @@ function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => { if (suggestion.iconsrc) { - return favicon; + return favicon; } if (suggestion.icon) { const iconClass = makeIconClass(suggestion.icon, true); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 9a39aa2349..ba55928d2b 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -10,7 +10,6 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; import { WOS, globalStore } from "@/store/global"; -import { isDev } from "@/util/isdev"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; @@ -480,6 +479,7 @@ export class WebViewModel implements ViewModel { if (checkKeyPressed(e, "Cmd:o")) { const curVal = globalStore.get(this.typeaheadOpen); globalStore.set(this.typeaheadOpen, !curVal); + return true; } return false; } @@ -600,9 +600,6 @@ interface WebViewProps { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { - if (!isDev) { - return null; - } return ( +// 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() { + // When done, ensure that we clear the “fetching” flag. + defer func() { + 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) + } + + return "data:" + mimeType + ";base64," + b64Data, nil +} + +// --- Stub functions for cache access --- +// +// Replace these stubs with your custom backend implementation. + +func GetFromCache(key string) (FaviconCacheItem, bool) { + faviconCacheLock.Lock() + defer faviconCacheLock.Unlock() + item, found := faviconCache[key] + if !found { + return FaviconCacheItem{}, false + } + return *item, true +} + +func SetInCache(key string, item FaviconCacheItem) { + faviconCacheLock.Lock() + defer faviconCacheLock.Unlock() + faviconCache[key] = &item +} diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index d139e974e8..533136e279 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -14,6 +14,7 @@ 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" @@ -143,7 +144,7 @@ var Bookmarks = []BookmarkType{ }, { Title: "Wave Terminal", - Url: "https://waveterm.com", + Url: "https://waveterm.dev", }, { Title: "Wave Github", @@ -169,7 +170,7 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) } -func fetchBookmarkSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { +func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { if data.SuggestionType != "bookmark" { return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) } @@ -287,11 +288,7 @@ func fetchBookmarkSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsD // Build up to 50 suggestions. var suggestions []wshrpc.SuggestionType - for i, entry := range scoredEntries { - if i >= 50 { - break - } - + for _, entry := range scoredEntries { var display, subText string if entry.bookmark.Title != "" { display = entry.bookmark.Title @@ -311,7 +308,11 @@ func fetchBookmarkSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsD Score: entry.score, UrlUrl: entry.bookmark.Url, } + suggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url) suggestions = append(suggestions, suggestion) + if len(suggestions) >= 50 { + break + } } return &wshrpc.FetchSuggestionsResponse{ @@ -321,7 +322,7 @@ func fetchBookmarkSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsD } // FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching. -func fetchFileSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { +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) From 9779c53cefdd552c08454ec84568faf616fe17b4 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 14:29:06 -0800 Subject: [PATCH 06/16] rename typeahead to suggestioncontrol... also add web:partitioin --- frontend/app/store/keymodel.ts | 2 +- .../suggestion.tsx} | 19 +++++++++++++------ frontend/app/view/preview/preview.tsx | 4 ++-- frontend/app/view/webview/webview.tsx | 7 +++++-- frontend/types/gotypes.d.ts | 1 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 5 +++-- 7 files changed, 26 insertions(+), 13 deletions(-) rename frontend/app/{typeahead/typeahead.tsx => suggestion/suggestion.tsx} (94%) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 4ced3db7cb..142d4c5212 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -397,7 +397,7 @@ function registerGlobalKeys() { }); const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view - allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft"); + allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); getApi().registerGlobalWebviewKeys(allKeys); } diff --git a/frontend/app/typeahead/typeahead.tsx b/frontend/app/suggestion/suggestion.tsx similarity index 94% rename from frontend/app/typeahead/typeahead.tsx rename to frontend/app/suggestion/suggestion.tsx index 764c5b329d..5e331d3d19 100644 --- a/frontend/app/typeahead/typeahead.tsx +++ b/frontend/app/suggestion/suggestion.tsx @@ -24,10 +24,17 @@ type BlockHeaderTypeaheadProps = Omit & openAtom: Atom; }; -const Typeahead: React.FC = ({ anchorRef, isOpen, onClose, onSelect, fetchSuggestions, className }) => { +const SuggestionControl: React.FC = ({ + anchorRef, + isOpen, + onClose, + onSelect, + fetchSuggestions, + className, +}) => { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; - return ; + return ; }; function highlightPositions(target: string, positions: number[]): ReactNode[] { @@ -114,7 +121,7 @@ const SuggestionContent: React.FC<{ return {highlightPositions(suggestion.display, suggestion.matchpos)}; }; -const BlockHeaderTypeahead: React.FC = (props) => { +const BlockHeaderSuggestionControl: React.FC = (props) => { const [headerElem, setHeaderElem] = useState(null); const isOpen = useAtomValue(props.openAtom); @@ -128,10 +135,10 @@ const BlockHeaderTypeahead: React.FC = (props) => { }, [props.blockRef.current]); const newClass = clsx(props.className, "rounded-t-none"); - return ; + return ; }; -const TypeaheadInner: React.FC> = ({ +const SuggestionControlInner: React.FC> = ({ anchorRef, onClose, onSelect, @@ -267,4 +274,4 @@ const TypeaheadInner: React.FC> = ({ ); }; -export { BlockHeaderTypeahead, Typeahead }; +export { BlockHeaderSuggestionControl, SuggestionControl }; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 8db761449e..01675288a5 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -9,7 +9,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; +import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; import { @@ -1148,7 +1148,7 @@ function PreviewView({
- model.updateOpenFileModalAndError(false)} diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index ba55928d2b..9fdd2f5f8d 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -8,7 +8,7 @@ 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 { BlockHeaderTypeahead } from "@/app/typeahead/typeahead"; +import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; @@ -477,6 +477,7 @@ export class WebViewModel implements ViewModel { return true; } if (checkKeyPressed(e, "Cmd:o")) { + // return false; // commented out for now const curVal = globalStore.get(this.typeaheadOpen); globalStore.set(this.typeaheadOpen, !curVal); return true; @@ -601,7 +602,7 @@ interface WebViewProps { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { return ( - model.setTypeaheadOpen(false)} @@ -627,6 +628,7 @@ const WebView = memo(({ model, onFailLoad, blockRef }: 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 }); @@ -835,6 +837,7 @@ const WebView = memo(({ model, onFailLoad, blockRef }: 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 && (
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4a3397fd4a..6f4a5d211c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -580,6 +580,7 @@ declare global { "term:conndebug"?: string; "web:zoom"?: number; "web:hidenav"?: boolean; + "web:partition"?: string; "markdown:fontsize"?: number; "markdown:fixedfontsize"?: number; "vdom:*"?: boolean; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index c6fec36d57..a3365eb8b1 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -106,6 +106,7 @@ const ( MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" + MetaKey_WebPartition = "web:partition" MetaKey_MarkdownFontSize = "markdown:fontsize" MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 6705aa4fd8..d583cc90c9 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -107,8 +107,9 @@ type MetaTSType struct { TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug - WebZoom float64 `json:"web:zoom,omitempty"` - WebHideNav *bool `json:"web:hidenav,omitempty"` + WebZoom float64 `json:"web:zoom,omitempty"` + WebHideNav *bool `json:"web:hidenav,omitempty"` + WebPartition string `json:"web:partition,omitempty"` MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"` MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"` From 6b4a50154f4a744a7e41605e9dfb5c86912d51a5 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 14:30:34 -0800 Subject: [PATCH 07/16] rename props --- frontend/app/suggestion/suggestion.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/app/suggestion/suggestion.tsx b/frontend/app/suggestion/suggestion.tsx index 5e331d3d19..f113fada46 100644 --- a/frontend/app/suggestion/suggestion.tsx +++ b/frontend/app/suggestion/suggestion.tsx @@ -8,7 +8,7 @@ import clsx from "clsx"; import { Atom, useAtomValue } from "jotai"; import React, { ReactNode, useEffect, useId, useRef, useState } from "react"; -interface TypeaheadProps { +interface SuggestionControlProps { anchorRef: React.RefObject; isOpen: boolean; onClose: () => void; @@ -19,12 +19,12 @@ interface TypeaheadProps { placeholderText?: string; } -type BlockHeaderTypeaheadProps = Omit & { +type BlockHeaderSuggestionControlProps = Omit & { blockRef: React.RefObject; openAtom: Atom; }; -const SuggestionControl: React.FC = ({ +const SuggestionControl: React.FC = ({ anchorRef, isOpen, onClose, @@ -121,7 +121,7 @@ const SuggestionContent: React.FC<{ return {highlightPositions(suggestion.display, suggestion.matchpos)}; }; -const BlockHeaderSuggestionControl: React.FC = (props) => { +const BlockHeaderSuggestionControl: React.FC = (props) => { const [headerElem, setHeaderElem] = useState(null); const isOpen = useAtomValue(props.openAtom); @@ -138,7 +138,7 @@ const BlockHeaderSuggestionControl: React.FC = (props return ; }; -const SuggestionControlInner: React.FC> = ({ +const SuggestionControlInner: React.FC> = ({ anchorRef, onClose, onSelect, @@ -223,8 +223,6 @@ const SuggestionControlInner: React.FC> = ({ } }; - console.log("TypeaheadInner", suggestions); - return (
Date: Fri, 7 Feb 2025 14:52:41 -0800 Subject: [PATCH 08/16] infuse life into the bookmarks feature --- frontend/types/gotypes.d.ts | 11 ++++++++ pkg/suggestion/suggestion.go | 51 +++++++++++------------------------ pkg/util/utilfn/utilfn.go | 8 ++++++ pkg/wconfig/settingsconfig.go | 10 +++++++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 6f4a5d211c..f9e8be741d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -467,6 +467,7 @@ declare global { presets: {[key: string]: MetaType}; termthemes: {[key: string]: TermThemeType}; connections: {[key: string]: ConnKeywords}; + bookmarks: {[key: string]: WebBookmark}; configerrors: ConfigError[]; }; @@ -1286,6 +1287,16 @@ declare global { lastfocusts: number; }; + // wconfig.WebBookmark + type WebBookmark = { + url: string; + title?: string; + icon?: string; + iconcolor?: string; + iconurl?: string; + "display:order"?: number; + }; + // service.WebCallType type WebCallType = { service: string; diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index 533136e279..703601f527 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -18,6 +18,7 @@ import ( "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" ) @@ -127,39 +128,6 @@ func resolveFileQuery(cwd string, query string) (string, string, string, error) return cwd, "", query, nil } -type BookmarkType struct { - FavIcon string `json:"favicon,omitempty"` - Title string `json:"title,omitempty"` - Url string `json:"url"` -} - -var Bookmarks = []BookmarkType{ - { - Title: "Google", - Url: "https://www.google.com", - }, - { - Title: "Claude AI", - Url: "https://claude.ai", - }, - { - Title: "Wave Terminal", - Url: "https://waveterm.dev", - }, - { - Title: "Wave Github", - Url: "https://github.com/wavetermdev/waveterm", - }, - { - Title: "Chat GPT (Open AI)", - Url: "https://chatgpt.com", - }, - { - Title: "Wave Pull Requests", - Url: "https://github.com/wavetermdev/waveterm/pulls", - }, -} - func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { if data.SuggestionType == "file" { return fetchFileSuggestions(ctx, data) @@ -179,13 +147,15 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat // 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 BookmarkType + 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 + searchTerm := data.Query var patternRunes []rune if searchTerm != "" { @@ -195,7 +165,18 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat var scoredEntries []scoredEntry var slab util.Slab - for i, bookmark := range Bookmarks { + bookmarkKeys := utilfn.GetKeys[wconfig.WebBookmark](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{ diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index 3b67035a7e..3a51278f55 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -1023,3 +1023,11 @@ func QuickHashString(s string) string { h.Write([]byte(s)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } + +func GetKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + return keys +} diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index bb85899188..a1829d233c 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -125,6 +125,15 @@ type ConfigError struct { Err string `json:"err"` } +type WebBookmark struct { + Url string `json:"url"` + Title string `json:"title,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"iconcolor,omitempty"` + IconUrl string `json:"iconurl,omitempty"` + DisplayOrder float64 `json:"display:order,omitempty"` +} + type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` @@ -133,6 +142,7 @@ type FullConfigType struct { Presets map[string]waveobj.MetaMapType `json:"presets"` TermThemes map[string]TermThemeType `json:"termthemes"` Connections map[string]ConnKeywords `json:"connections"` + Bookmarks map[string]WebBookmark `json:"bookmarks"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` } type ConnKeywords struct { From 0d803f650d4e69f05b930853b6288ee1245308c4 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:26:15 -0800 Subject: [PATCH 09/16] special nodata/noresults handlers --- frontend/app/suggestion/suggestion.tsx | 98 ++++++++++++++++++-------- frontend/app/view/webview/webview.tsx | 44 +++++++++++- 2 files changed, 111 insertions(+), 31 deletions(-) diff --git a/frontend/app/suggestion/suggestion.tsx b/frontend/app/suggestion/suggestion.tsx index f113fada46..84b6340204 100644 --- a/frontend/app/suggestion/suggestion.tsx +++ b/frontend/app/suggestion/suggestion.tsx @@ -17,6 +17,7 @@ interface SuggestionControlProps { fetchSuggestions: SuggestionsFnType; className?: string; placeholderText?: string; + children?: React.ReactNode; } type BlockHeaderSuggestionControlProps = Omit & { @@ -31,10 +32,11 @@ const SuggestionControl: React.FC = ({ onSelect, fetchSuggestions, className, + children, }) => { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; - return ; + return ; }; function highlightPositions(target: string, positions: number[]): ReactNode[] { @@ -50,7 +52,11 @@ function highlightPositions(target: string, positions: number[]): ReactNode[] { while (targetIndex < target.length) { if (posIndex < positions.length && targetIndex === positions[posIndex]) { - result.push({target[targetIndex]}); + result.push( + + {target[targetIndex]} + + ); posIndex++; } else { result.push(target[targetIndex]); @@ -138,7 +144,29 @@ const BlockHeaderSuggestionControl: React.FC return ; }; -const SuggestionControlInner: React.FC> = ({ +/** + * The empty state component that can be used as a child of SuggestionControl. + * If no children are provided to SuggestionControl, this default empty state will be used. + */ +const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( +
+ {children ?? No Suggestions} +
+ ); +}; + +const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( +
+ {children ?? No Suggestions} +
+ ); +}; + +interface SuggestionControlInnerProps extends Omit {} + +const SuggestionControlInner: React.FC = ({ anchorRef, onClose, onSelect, @@ -146,11 +174,12 @@ const SuggestionControlInner: React.FC> = fetchSuggestions, className, placeholderText, + children, }) => { const widgetId = useId(); const [query, setQuery] = useState(""); const reqNumRef = useRef(0); - const [suggestions, setSuggestions] = useState([]); + let [suggestions, setSuggestions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [fetched, setFetched] = useState(false); const inputRef = useRef(null); @@ -160,6 +189,12 @@ const SuggestionControlInner: React.FC> = 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); @@ -168,7 +203,7 @@ const SuggestionControlInner: React.FC> = useEffect(() => { reqNumRef.current++; fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => { - if (results.reqnum != reqNumRef.current) { + if (results.reqnum !== reqNumRef.current) { return; } setSuggestions(results.suggestions ?? []); @@ -222,7 +257,6 @@ const SuggestionControlInner: React.FC> = } } }; - return (
> = placeholder={placeholderText} />
- {fetched && suggestions.length > 0 && ( -
- {suggestions.map((suggestion, index) => ( -
{ - onSelect(suggestion, query); - onClose(); - }} - > - - -
- ))} -
- )} + {fetched && + (suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion, index) => ( +
{ + onSelect(suggestion, query); + onClose(); + }} + > + + +
+ ))} +
+ ) : ( + // Render the empty state (either a provided child or the default) +
+ {query === "" + ? (noDataChild ?? ) + : (emptyStateChild ?? )} +
+ ))}
); }; -export { BlockHeaderSuggestionControl, SuggestionControl }; +export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults }; diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 9fdd2f5f8d..726ae9c44f 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -3,12 +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 } from "@/app/suggestion/suggestion"; +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"; @@ -601,6 +605,19 @@ interface WebViewProps { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + const openBookmarksJson = () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/presets/bookmarks.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, false, true); + model.setTypeaheadOpen(false); + }); + }; return ( + > + +
+

No Bookmarks Configured

+

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

+ +
+
+ + +
+

No matching bookmarks

+
+
+
); } ); From 1f2f7577391ddf84781e7be0d659b617acd8a378 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:27:39 -0800 Subject: [PATCH 10/16] add an edit bookmarks.json --- frontend/app/view/webview/webview.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 726ae9c44f..79657cbb61 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -650,6 +650,12 @@ const BookmarkTypeahead = memo(

No matching bookmarks

+
From a9dac0ccf97cc7275e1cb097fc5f4c01d7517744 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:35:20 -0800 Subject: [PATCH 11/16] fix button styling --- frontend/app/view/webview/webview.tsx | 4 ++-- frontend/tailwindsetup.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 79657cbb61..2555e7337b 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -640,7 +640,7 @@ const BookmarkTypeahead = memo(

@@ -652,7 +652,7 @@ const BookmarkTypeahead = memo(

No matching bookmarks

diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index 64807f64d1..578567a921 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -29,6 +29,7 @@ --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; From e1791f7ae246246cf9e79c061b338d70b76c1008 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:51:57 -0800 Subject: [PATCH 12/16] use getmapkeys --- pkg/suggestion/suggestion.go | 2 +- pkg/util/utilfn/utilfn.go | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index 703601f527..2585f4e82e 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -165,7 +165,7 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat var scoredEntries []scoredEntry var slab util.Slab - bookmarkKeys := utilfn.GetKeys[wconfig.WebBookmark](bookmarks) + bookmarkKeys := utilfn.GetMapKeys(bookmarks) // sort by display:order and then by key sort.Slice(bookmarkKeys, func(i, j int) bool { bookmarkA := bookmarks[bookmarkKeys[i]] diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index 3a51278f55..3b67035a7e 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -1023,11 +1023,3 @@ func QuickHashString(s string) string { h.Write([]byte(s)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } - -func GetKeys[T any](m map[string]T) []string { - keys := make([]string, 0, len(m)) - for key := range m { - keys = append(keys, key) - } - return keys -} From e0a974c42f955a460ec7292e7aae0576a53477f4 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:56:29 -0800 Subject: [PATCH 13/16] add some url validation --- pkg/suggestion/suggestion.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index 2585f4e82e..d127dab05d 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io/fs" + "net/url" "os" "path/filepath" "sort" @@ -138,6 +139,22 @@ func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*w 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) @@ -155,6 +172,7 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat } bookmarks := wconfig.GetWatcher().GetFullConfig().Bookmarks + bookmarks = filterBookmarksForValid(bookmarks) searchTerm := data.Query var patternRunes []rune From 1d7e490bca9110ba94d7b5b1bf69626b313fcac1 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 15:58:37 -0800 Subject: [PATCH 14/16] cleanups --- frontend/app/view/webview/webview.tsx | 1 - pkg/faviconcache/faviconcache.go | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 2555e7337b..8b96a674ef 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -481,7 +481,6 @@ export class WebViewModel implements ViewModel { return true; } if (checkKeyPressed(e, "Cmd:o")) { - // return false; // commented out for now const curVal = globalStore.get(this.typeaheadOpen); globalStore.set(this.typeaheadOpen, !curVal); return true; diff --git a/pkg/faviconcache/faviconcache.go b/pkg/faviconcache/faviconcache.go index 32d1e5992d..964ca2384d 100644 --- a/pkg/faviconcache/faviconcache.go +++ b/pkg/faviconcache/faviconcache.go @@ -11,6 +11,7 @@ import ( "log" "net/http" "net/url" + "strings" "sync" "time" ) @@ -156,6 +157,10 @@ func fetchFavicon(domain string) (string, error) { mimeType = http.DetectContentType(data) } + if !strings.HasPrefix(mimeType, "image/") { + return "", fmt.Errorf("unexpected MIME type: %s", mimeType) + } + return "data:" + mimeType + ";base64," + b64Data, nil } From bdde6868a667e89ba00da2eca6fb90700ec2c293 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 16:03:35 -0800 Subject: [PATCH 15/16] limit to 5 concurrent requests --- pkg/faviconcache/faviconcache.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/faviconcache/faviconcache.go b/pkg/faviconcache/faviconcache.go index 964ca2384d..3fa12dbee8 100644 --- a/pkg/faviconcache/faviconcache.go +++ b/pkg/faviconcache/faviconcache.go @@ -14,6 +14,8 @@ import ( "strings" "sync" "time" + + "github.com/wavetermdev/waveterm/pkg/panichandler" ) // --- Constants and Types --- @@ -39,6 +41,9 @@ var ( 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) @@ -94,8 +99,16 @@ func triggerAsyncFetch(domain string) { 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() @@ -164,9 +177,7 @@ func fetchFavicon(domain string) (string, error) { return "data:" + mimeType + ";base64," + b64Data, nil } -// --- Stub functions for cache access --- -// -// Replace these stubs with your custom backend implementation. +// TODO store in blockstore func GetFromCache(key string) (FaviconCacheItem, bool) { faviconCacheLock.Lock() From 3bc713c92ed29fcfc5f24de57ff2f019a661eb24 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Feb 2025 16:07:45 -0800 Subject: [PATCH 16/16] const for maxsuggestions --- pkg/suggestion/suggestion.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/suggestion/suggestion.go b/pkg/suggestion/suggestion.go index d127dab05d..133ffc1da8 100644 --- a/pkg/suggestion/suggestion.go +++ b/pkg/suggestion/suggestion.go @@ -23,6 +23,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshrpc" ) +const MaxSuggestions = 50 + type MockDirEntry struct { NameStr string IsDirVal bool @@ -285,7 +287,7 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat return scoredEntries[i].origIndex < scoredEntries[j].origIndex }) - // Build up to 50 suggestions. + // Build up to MaxSuggestions suggestions. var suggestions []wshrpc.SuggestionType for _, entry := range scoredEntries { var display, subText string @@ -309,7 +311,7 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat } suggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url) suggestions = append(suggestions, suggestion) - if len(suggestions) >= 50 { + if len(suggestions) >= MaxSuggestions { break } } @@ -416,12 +418,9 @@ func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) ( }) } - // 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) @@ -439,10 +438,13 @@ func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) ( Display: suggestionFileName, FileName: suggestionFileName, FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), - MatchPos: scoredEntries[i].positions, + MatchPos: candidate.positions, Score: candidate.score, } suggestions = append(suggestions, s) + if len(suggestions) >= MaxSuggestions { + break + } } return &wshrpc.FetchSuggestionsResponse{