From 4b2018934d4ae5fab60f3a4973847c4f21af97c8 Mon Sep 17 00:00:00 2001 From: qianlifeng Date: Wed, 30 Jul 2025 19:54:31 +0800 Subject: [PATCH] feat(mru): Implement Most Recently Used (MRU) functionality across plugins - Added MRU support to SysPlugin and UrlPlugin, allowing users to access recently used commands and URLs. - Introduced new data structures for MRU items and context data. - Updated the query methods to include MRU results based on user settings. - Modified the settings management to accommodate the new query mode for MRU. - Enhanced the UI to reflect the changes in query modes and added relevant translations. - Created a dedicated MRU manager for handling MRU items in the database. - Updated Flutter UI to support MRU queries and settings. --- wox.core/database/database.go | 14 ++ wox.core/main.go | 3 + wox.core/migration/migrator.go | 2 +- wox.core/plugin/api.go | 10 ++ wox.core/plugin/instance.go | 1 + wox.core/plugin/manager.go | 103 +++++++++++- wox.core/plugin/metadata.go | 4 + wox.core/plugin/mru.go | 13 ++ wox.core/plugin/query.go | 1 + wox.core/plugin/system/app/app.go | 116 ++++++++++++- wox.core/plugin/system/app/app_darwin_test.go | 3 + wox.core/plugin/system/browser_bookmark.go | 65 +++++++- .../plugin/system/browser_bookmark_test.go | 2 + wox.core/plugin/system/indicator.go | 86 +++++++++- wox.core/plugin/system/sys.go | 62 ++++++- wox.core/plugin/system/url.go | 119 +++++++++++++- wox.core/resource/lang/en_US.json | 10 +- wox.core/resource/lang/pt_BR.json | 3 + wox.core/resource/lang/ru_RU.json | 3 + wox.core/resource/lang/zh_CN.json | 14 +- wox.core/setting/manager.go | 72 ++++++++- wox.core/setting/mru.go | 153 ++++++++++++++++++ wox.core/setting/wox_setting.go | 11 +- wox.core/ui/dto/setting_dto.go | 2 +- wox.core/ui/router.go | 27 +++- wox.core/ui/ui_impl.go | 2 +- wox.ui.flutter/wox/lib/api/wox_api.dart | 8 + .../controllers/wox_launcher_controller.dart | 51 ++++-- wox.ui.flutter/wox/lib/entity/wox_query.dart | 15 +- .../wox/lib/entity/wox_setting.dart | 8 +- .../lib/enums/wox_last_query_mode_enum.dart | 13 -- .../wox/lib/enums/wox_query_mode_enum.dart | 14 ++ .../views/wox_setting_general_view.dart | 22 +-- 33 files changed, 938 insertions(+), 94 deletions(-) create mode 100644 wox.core/plugin/mru.go create mode 100644 wox.core/setting/mru.go delete mode 100644 wox.ui.flutter/wox/lib/enums/wox_last_query_mode_enum.dart create mode 100644 wox.ui.flutter/wox/lib/enums/wox_query_mode_enum.dart diff --git a/wox.core/database/database.go b/wox.core/database/database.go index b6a38f01..8ccaca13 100644 --- a/wox.core/database/database.go +++ b/wox.core/database/database.go @@ -36,6 +36,19 @@ type Oplog struct { SyncedToCloud bool `gorm:"default:false"` } +type MRURecord struct { + Hash string `gorm:"primaryKey"` // MD5 hash of pluginId+title+subTitle + PluginID string `gorm:"not null"` + Title string `gorm:"not null"` + SubTitle string + Icon string // JSON serialized WoxImage + ContextData string // Plugin context data for restoration + LastUsed int64 `gorm:"not null"` + UseCount int `gorm:"default:1"` + CreatedAt time.Time + UpdatedAt time.Time +} + func Init(ctx context.Context) error { dbPath := filepath.Join(util.GetLocation().GetUserDataDirectory(), "wox.db") @@ -86,6 +99,7 @@ func Init(ctx context.Context) error { &WoxSetting{}, &PluginSetting{}, &Oplog{}, + &MRURecord{}, ) if err != nil { return fmt.Errorf("failed to migrate database schema: %w", err) diff --git a/wox.core/main.go b/wox.core/main.go index cc33f3e2..831196a0 100644 --- a/wox.core/main.go +++ b/wox.core/main.go @@ -163,6 +163,9 @@ func main() { // Start auto backup if enabled setting.GetSettingManager().StartAutoBackup(ctx) + // Start MRU cleanup + setting.GetSettingManager().StartMRUCleanup(ctx) + // Start auto update checker if enabled updater.StartAutoUpdateChecker(ctx) diff --git a/wox.core/migration/migrator.go b/wox.core/migration/migrator.go index 56ea4655..d6c1c2de 100644 --- a/wox.core/migration/migrator.go +++ b/wox.core/migration/migrator.go @@ -224,7 +224,7 @@ func Run(ctx context.Context) error { "HideOnLostFocus": oldSettings.HideOnLostFocus, "ShowTray": oldSettings.ShowTray, "LangCode": oldSettings.LangCode, - "LastQueryMode": oldSettings.LastQueryMode, + "QueryMode": oldSettings.LastQueryMode, // Migrate LastQueryMode to QueryMode "ShowPosition": oldSettings.ShowPosition, "EnableAutoBackup": oldSettings.EnableAutoBackup, "EnableAutoUpdate": oldSettings.EnableAutoUpdate, diff --git a/wox.core/plugin/api.go b/wox.core/plugin/api.go index 00893d00..b34485b5 100644 --- a/wox.core/plugin/api.go +++ b/wox.core/plugin/api.go @@ -37,6 +37,7 @@ type API interface { OnGetDynamicSetting(ctx context.Context, callback func(key string) string) OnDeepLink(ctx context.Context, callback func(arguments map[string]string)) OnUnload(ctx context.Context, callback func()) + OnMRURestore(ctx context.Context, callback func(mruData MRUData) (*QueryResult, error)) RegisterQueryCommands(ctx context.Context, commands []MetadataCommand) AIChatStream(ctx context.Context, model common.Model, conversations []common.Conversation, options common.ChatOptions, callback common.ChatStreamFunc) error } @@ -305,6 +306,15 @@ func (a *APIImpl) applyStartTimeIfAbsent(streamResult *common.ChatStreamData) { } } +func (a *APIImpl) OnMRURestore(ctx context.Context, callback func(mruData MRUData) (*QueryResult, error)) { + if !a.pluginInstance.Metadata.IsSupportFeature(MetadataFeatureMRU) { + a.Log(ctx, LogLevelError, "plugin has no access to MRU feature") + return + } + + a.pluginInstance.MRURestoreCallbacks = append(a.pluginInstance.MRURestoreCallbacks, callback) +} + func NewAPI(instance *Instance) API { apiImpl := &APIImpl{pluginInstance: instance} logFolder := path.Join(util.GetLocation().GetLogPluginDirectory(), instance.Metadata.Name) diff --git a/wox.core/plugin/instance.go b/wox.core/plugin/instance.go index 542f186d..1451dcf9 100644 --- a/wox.core/plugin/instance.go +++ b/wox.core/plugin/instance.go @@ -19,6 +19,7 @@ type Instance struct { SettingChangeCallbacks []func(key string, value string) DeepLinkCallbacks []func(arguments map[string]string) UnloadCallbacks []func() + MRURestoreCallbacks []func(mruData MRUData) (*QueryResult, error) // MRU restore callbacks // for measure performance LoadStartTimestamp int64 diff --git a/wox.core/plugin/manager.go b/wox.core/plugin/manager.go index 5a9dfbef..2d94cfbe 100644 --- a/wox.core/plugin/manager.go +++ b/wox.core/plugin/manager.go @@ -820,6 +820,8 @@ func (m *Manager) PolishResult(ctx context.Context, pluginInstance *Instance, qu } } + originalIcon := result.Icon + // convert icon result.Icon = common.ConvertIcon(ctx, result.Icon, pluginInstance.PluginDirectory) for i := range result.Tails { @@ -883,6 +885,7 @@ func (m *Manager) PolishResult(ctx context.Context, pluginInstance *Instance, qu ResultTitle: result.Title, ResultSubTitle: result.SubTitle, ContextData: result.ContextData, + Icon: originalIcon, PluginInstance: pluginInstance, Query: query, Actions: util.NewHashMap[string, func(ctx context.Context, actionContext ActionContext)](), @@ -1385,13 +1388,41 @@ func (m *Manager) ExecuteAction(ctx context.Context, resultId string, actionId s ContextData: resultCache.ContextData, }) - util.Go(ctx, fmt.Sprintf("[%s] add actioned result", resultCache.PluginInstance.Metadata.Name), func() { - setting.GetSettingManager().AddActionedResult(ctx, resultCache.PluginInstance.Metadata.Id, resultCache.ResultTitle, resultCache.ResultSubTitle, resultCache.Query.RawQuery) + util.Go(ctx, fmt.Sprintf("[%s] post execute action", resultCache.PluginInstance.Metadata.Name), func() { + m.postExecuteAction(ctx, resultCache) }) return nil } +func (m *Manager) postExecuteAction(ctx context.Context, resultCache *QueryResultCache) { + // Add actioned result for statistics + setting.GetSettingManager().AddActionedResult(ctx, resultCache.PluginInstance.Metadata.Id, resultCache.ResultTitle, resultCache.ResultSubTitle, resultCache.Query.RawQuery) + + // Add to MRU if plugin supports it + if resultCache.PluginInstance.Metadata.IsSupportFeature(MetadataFeatureMRU) { + mruItem := setting.MRUItem{ + PluginID: resultCache.PluginInstance.Metadata.Id, + Title: resultCache.ResultTitle, + SubTitle: resultCache.ResultSubTitle, + Icon: resultCache.Icon, + ContextData: resultCache.ContextData, + } + if err := setting.GetSettingManager().AddMRUItem(ctx, mruItem); err != nil { + util.GetLogger().Error(ctx, fmt.Sprintf("failed to add MRU item: %s", err.Error())) + } + } + + // Add to query history only if query is not empty (skip empty queries like MRU) + if resultCache.Query.RawQuery != "" { + plainQuery := common.PlainQuery{ + QueryType: resultCache.Query.Type, + QueryText: resultCache.Query.RawQuery, + } + setting.GetSettingManager().AddQueryHistory(ctx, plainQuery) + } +} + func (m *Manager) ExecuteRefresh(ctx context.Context, refreshableResultWithId RefreshableResultWithResultId) (RefreshableResultWithResultId, error) { var refreshableResult RefreshableResult copyErr := copier.Copy(&refreshableResult, &refreshableResultWithId) @@ -1606,3 +1637,71 @@ func (m *Manager) ExecutePluginDeeplink(ctx context.Context, pluginId string, ar }) } } + +func (m *Manager) QueryMRU(ctx context.Context) []QueryResultUI { + mruItems, err := setting.GetSettingManager().GetMRUItems(ctx, 10) + if err != nil { + util.GetLogger().Error(ctx, fmt.Sprintf("failed to get MRU items: %s", err.Error())) + return []QueryResultUI{} + } + + var results []QueryResultUI + for _, item := range mruItems { + pluginInstance := m.getPluginInstance(item.PluginID) + if pluginInstance == nil { + continue + } + if !pluginInstance.Metadata.IsSupportFeature(MetadataFeatureMRU) { + continue + } + + if restored := m.restoreFromMRU(ctx, pluginInstance, item); restored != nil { + polishedResult := m.PolishResult(ctx, pluginInstance, Query{}, *restored) + results = append(results, polishedResult.ToUI()) + } + } + + return results +} + +// getPluginInstance finds a plugin instance by ID +func (m *Manager) getPluginInstance(pluginID string) *Instance { + pluginInstance, found := lo.Find(m.instances, func(item *Instance) bool { + return item.Metadata.Id == pluginID + }) + if found { + return pluginInstance + } + return nil +} + +// restoreFromMRU attempts to restore a QueryResult from MRU data +func (m *Manager) restoreFromMRU(ctx context.Context, pluginInstance *Instance, item setting.MRUItem) *QueryResult { + // For Go plugins, call MRU restore callbacks directly + if len(pluginInstance.MRURestoreCallbacks) > 0 { + mruData := MRUData{ + PluginID: item.PluginID, + Title: item.Title, + SubTitle: item.SubTitle, + Icon: item.Icon, + ContextData: item.ContextData, + LastUsed: item.LastUsed, + UseCount: item.UseCount, + } + + // Call the first (and typically only) MRU restore callback + if restored, err := pluginInstance.MRURestoreCallbacks[0](mruData); err == nil { + return restored + } else { + util.GetLogger().Debug(ctx, fmt.Sprintf("MRU restore failed for plugin %s: %s", pluginInstance.Metadata.Name, err.Error())) + } + } + + // For external plugins (Python/Node.js), MRU support will be implemented later + // Currently only Go plugins support MRU functionality + if pluginInstance.Host != nil { + util.GetLogger().Debug(ctx, fmt.Sprintf("External plugin MRU restore not yet implemented for plugin %s", pluginInstance.Metadata.Name)) + } + + return nil +} diff --git a/wox.core/plugin/metadata.go b/wox.core/plugin/metadata.go index 19179e4c..d9cb98c9 100644 --- a/wox.core/plugin/metadata.go +++ b/wox.core/plugin/metadata.go @@ -37,6 +37,10 @@ const ( // by default, the width ratio is 0.5, which means the result list and preview panel have the same width // if the width ratio is 0.3, which means the result list takes 30% of the width and the preview panel takes 70% of the width MetadataFeatureResultPreviewWidthRatio MetadataFeatureName = "resultPreviewWidthRatio" + + // enable this feature to support MRU (Most Recently Used) functionality + // plugin must implement OnMRURestore callback to restore results from MRU data + MetadataFeatureMRU MetadataFeatureName = "mru" ) // Metadata parsed from plugin.json, see `Plugin.json.md` for more detail diff --git a/wox.core/plugin/mru.go b/wox.core/plugin/mru.go new file mode 100644 index 00000000..9aaa3216 --- /dev/null +++ b/wox.core/plugin/mru.go @@ -0,0 +1,13 @@ +package plugin + +import "wox/common" + +type MRUData struct { + PluginID string `json:"pluginId"` + Title string `json:"title"` + SubTitle string `json:"subTitle"` + Icon common.WoxImage `json:"icon"` + ContextData string `json:"contextData"` + LastUsed int64 `json:"lastUsed"` + UseCount int `json:"useCount"` +} diff --git a/wox.core/plugin/query.go b/wox.core/plugin/query.go index 0f5c31d2..f8d74c0a 100644 --- a/wox.core/plugin/query.go +++ b/wox.core/plugin/query.go @@ -210,6 +210,7 @@ type QueryResultCache struct { ResultTitle string ResultSubTitle string ContextData string + Icon common.WoxImage Refresh func(context.Context, RefreshableResult) RefreshableResult PluginInstance *Instance Query Query diff --git a/wox.core/plugin/system/app/app.go b/wox.core/plugin/system/app/app.go index 484b5e6d..f69bb442 100644 --- a/wox.core/plugin/system/app/app.go +++ b/wox.core/plugin/system/app/app.go @@ -43,6 +43,12 @@ type appInfo struct { Pid int `json:"-"` } +type appContextData struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` +} + func (a *appInfo) GetDisplayPath() string { if a.Type == AppTypeUWP { return "" @@ -92,6 +98,11 @@ func (a *ApplicationPlugin) GetMetadata() plugin.Metadata { "Macos", "Linux", }, + Features: []plugin.MetadataFeature{ + { + Name: plugin.MetadataFeatureMRU, + }, + }, SettingDefinitions: []definition.PluginSettingDefinitionItem{ { Type: definition.PluginSettingDefinitionTypeTable, @@ -140,6 +151,8 @@ func (a *ApplicationPlugin) Init(ctx context.Context, initParams plugin.InitPara a.indexApps(ctx) } }) + + a.api.OnMRURestore(ctx, a.handleMRURestore) } func (a *ApplicationPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult { @@ -149,12 +162,21 @@ func (a *ApplicationPlugin) Query(ctx context.Context, query plugin.Query) []plu isPathNameMatch, pathNameScore := system.IsStringMatchScore(ctx, filepath.Base(info.Path), query.Search) if isNameMatch || isPathNameMatch { displayPath := info.GetDisplayPath() + + contextData := appContextData{ + Name: info.Name, + Path: info.Path, + Type: info.Type, + } + contextDataJson, _ := json.Marshal(contextData) + result := plugin.QueryResult{ - Id: uuid.NewString(), - Title: info.Name, - SubTitle: displayPath, - Icon: info.Icon, - Score: util.MaxInt64(nameScore, pathNameScore), + Id: uuid.NewString(), + Title: info.Name, + SubTitle: displayPath, + Icon: info.Icon, + Score: util.MaxInt64(nameScore, pathNameScore), + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_app_open", @@ -525,3 +547,87 @@ func (a *ApplicationPlugin) removeDuplicateApps(ctx context.Context, apps []appI a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("removed %d duplicate apps, %d apps remaining", len(apps)-len(result), len(result))) return result } + +func (a *ApplicationPlugin) handleMRURestore(mruData plugin.MRUData) (*plugin.QueryResult, error) { + var contextData appContextData + if err := json.Unmarshal([]byte(mruData.ContextData), &contextData); err != nil { + return nil, fmt.Errorf("failed to parse context data: %w", err) + } + + var appInfo *appInfo + for _, info := range a.apps { + if info.Name == contextData.Name && info.Path == contextData.Path { + appInfo = &info + break + } + } + + if appInfo == nil { + return nil, fmt.Errorf("app not found: %s", contextData.Name) + } + + displayPath := appInfo.GetDisplayPath() + result := &plugin.QueryResult{ + Id: uuid.NewString(), + Title: appInfo.Name, + SubTitle: displayPath, + Icon: mruData.Icon, + ContextData: mruData.ContextData, + Actions: []plugin.QueryResultAction{ + { + Name: "i18n:plugin_app_open", + Icon: plugin.OpenIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + runErr := shell.Open(appInfo.Path) + if runErr != nil { + a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error opening app %s: %s", appInfo.Path, runErr.Error())) + a.api.Notify(ctx, fmt.Sprintf("i18n:plugin_app_open_failed_description: %s", runErr.Error())) + } + }, + }, + { + Name: "i18n:plugin_app_open_containing_folder", + Icon: plugin.OpenContainingFolderIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + if err := a.retriever.OpenAppFolder(ctx, *appInfo); err != nil { + a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error opening folder: %s", err.Error())) + } + }, + }, + { + Name: "i18n:plugin_app_copy_path", + Icon: plugin.CopyIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + clipboard.WriteText(appInfo.Path) + }, + }, + }, + } + + if appInfo.IsRunning() { + result.Actions = append(result.Actions, plugin.QueryResultAction{ + Name: "i18n:plugin_app_terminate", + Icon: plugin.TerminateAppIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + p, getErr := os.FindProcess(appInfo.Pid) + if getErr != nil { + a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error finding process %d: %s", appInfo.Pid, getErr.Error())) + return + } + + killErr := p.Kill() + if killErr != nil { + a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error killing process %d: %s", appInfo.Pid, killErr.Error())) + } + }, + }) + + result.RefreshInterval = 1000 + result.OnRefresh = func(ctx context.Context, result plugin.RefreshableResult) plugin.RefreshableResult { + result.Tails = a.getRunningProcessResult(appInfo.Pid) + return result + } + } + + return result, nil +} diff --git a/wox.core/plugin/system/app/app_darwin_test.go b/wox.core/plugin/system/app/app_darwin_test.go index d3f430b6..9acc0001 100644 --- a/wox.core/plugin/system/app/app_darwin_test.go +++ b/wox.core/plugin/system/app/app_darwin_test.go @@ -58,6 +58,9 @@ func (e emptyAPIImpl) AIChatStream(ctx context.Context, model common.Model, conv return nil } +func (e emptyAPIImpl) OnMRURestore(ctx context.Context, callback func(mruData plugin.MRUData) (*plugin.QueryResult, error)) { +} + func TestMacRetriever_ParseAppInfo(t *testing.T) { if util.IsMacOS() { util.GetLocation().Init() diff --git a/wox.core/plugin/system/browser_bookmark.go b/wox.core/plugin/system/browser_bookmark.go index cb7594f1..70f2192c 100644 --- a/wox.core/plugin/system/browser_bookmark.go +++ b/wox.core/plugin/system/browser_bookmark.go @@ -2,6 +2,7 @@ package system import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -23,6 +24,11 @@ type Bookmark struct { Url string } +type bookmarkContextData struct { + Name string `json:"name"` + Url string `json:"url"` +} + type BrowserBookmarkPlugin struct { api plugin.API bookmarks []Bookmark @@ -49,6 +55,11 @@ func (c *BrowserBookmarkPlugin) GetMetadata() plugin.Metadata { "Macos", "Linux", }, + Features: []plugin.MetadataFeature{ + { + Name: plugin.MetadataFeatureMRU, + }, + }, } } @@ -97,6 +108,8 @@ func (c *BrowserBookmarkPlugin) Init(ctx context.Context, initParams plugin.Init // Remove duplicate bookmarks (same name and url) c.bookmarks = c.removeDuplicateBookmarks(c.bookmarks) + + c.api.OnMRURestore(ctx, c.handleMRURestore) } func (c *BrowserBookmarkPlugin) Query(ctx context.Context, query plugin.Query) (results []plugin.QueryResult) { @@ -122,11 +135,18 @@ func (c *BrowserBookmarkPlugin) Query(ctx context.Context, query plugin.Query) ( } if isMatch { + contextData := bookmarkContextData{ + Name: bookmark.Name, + Url: bookmark.Url, + } + contextDataJson, _ := json.Marshal(contextData) + results = append(results, plugin.QueryResult{ - Title: bookmark.Name, - SubTitle: bookmark.Url, - Score: matchScore, - Icon: browserBookmarkIcon, + Title: bookmark.Name, + SubTitle: bookmark.Url, + Score: matchScore, + Icon: browserBookmarkIcon, + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_browser_bookmark_open_in_browser", @@ -235,3 +255,40 @@ func (c *BrowserBookmarkPlugin) removeDuplicateBookmarks(bookmarks []Bookmark) [ return result } + +func (c *BrowserBookmarkPlugin) handleMRURestore(mruData plugin.MRUData) (*plugin.QueryResult, error) { + var contextData bookmarkContextData + if err := json.Unmarshal([]byte(mruData.ContextData), &contextData); err != nil { + return nil, fmt.Errorf("failed to parse context data: %w", err) + } + + // Check if bookmark still exists in current bookmarks + found := false + for _, bookmark := range c.bookmarks { + if bookmark.Name == contextData.Name && bookmark.Url == contextData.Url { + found = true + break + } + } + + if !found { + return nil, fmt.Errorf("bookmark no longer exists: %s", contextData.Name) + } + + result := &plugin.QueryResult{ + Title: contextData.Name, + SubTitle: contextData.Url, + Icon: mruData.Icon, + ContextData: mruData.ContextData, + Actions: []plugin.QueryResultAction{ + { + Name: "i18n:plugin_browser_bookmark_open_in_browser", + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + shell.Open(contextData.Url) + }, + }, + }, + } + + return result, nil +} diff --git a/wox.core/plugin/system/browser_bookmark_test.go b/wox.core/plugin/system/browser_bookmark_test.go index 28dd3588..ffd293fd 100644 --- a/wox.core/plugin/system/browser_bookmark_test.go +++ b/wox.core/plugin/system/browser_bookmark_test.go @@ -270,3 +270,5 @@ func (m *mockAPI) RegisterQueryCommands(ctx context.Context, commands []plugin.M func (m *mockAPI) AIChatStream(ctx context.Context, model common.Model, conversations []common.Conversation, options common.ChatOptions, callback common.ChatStreamFunc) error { return nil } +func (m *mockAPI) OnMRURestore(ctx context.Context, callback func(mruData plugin.MRUData) (*plugin.QueryResult, error)) { +} diff --git a/wox.core/plugin/system/indicator.go b/wox.core/plugin/system/indicator.go index 432e33d1..0070344f 100644 --- a/wox.core/plugin/system/indicator.go +++ b/wox.core/plugin/system/indicator.go @@ -2,6 +2,7 @@ package system import ( "context" + "encoding/json" "fmt" "wox/common" "wox/i18n" @@ -21,6 +22,12 @@ type IndicatorPlugin struct { api plugin.API } +type indicatorContextData struct { + TriggerKeyword string `json:"triggerKeyword"` + PluginID string `json:"pluginId"` + Command string `json:"command,omitempty"` +} + func (i *IndicatorPlugin) GetMetadata() plugin.Metadata { return plugin.Metadata{ Id: "38564bf0-75ad-4b3e-8afe-a0e0a287c42e", @@ -41,11 +48,17 @@ func (i *IndicatorPlugin) GetMetadata() plugin.Metadata { "Macos", "Linux", }, + Features: []plugin.MetadataFeature{ + { + Name: plugin.MetadataFeatureMRU, + }, + }, } } func (i *IndicatorPlugin) Init(ctx context.Context, initParams plugin.InitParams) { i.api = initParams.API + i.api.OnMRURestore(ctx, i.handleMRURestore) } func (i *IndicatorPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult { @@ -55,12 +68,19 @@ func (i *IndicatorPlugin) Query(ctx context.Context, query plugin.Query) []plugi return triggerKeyword != "*" && IsStringMatchNoPinYin(ctx, triggerKeyword, query.Search) }) if found { + contextData := indicatorContextData{ + TriggerKeyword: triggerKeyword, + PluginID: pluginInstance.Metadata.Id, + } + contextDataJson, _ := json.Marshal(contextData) + results = append(results, plugin.QueryResult{ - Id: uuid.NewString(), - Title: triggerKeyword, - SubTitle: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_indicator_activate_plugin"), pluginInstance.Metadata.Name), - Score: 10, - Icon: pluginInstance.Metadata.GetIconOrDefault(pluginInstance.PluginDirectory, indicatorIcon), + Id: uuid.NewString(), + Title: triggerKeyword, + SubTitle: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_indicator_activate_plugin"), pluginInstance.Metadata.Name), + Score: 10, + Icon: pluginInstance.Metadata.GetIconOrDefault(pluginInstance.PluginDirectory, indicatorIcon), + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_indicator_activate", @@ -101,3 +121,59 @@ func (i *IndicatorPlugin) Query(ctx context.Context, query plugin.Query) []plugi } return results } + +func (i *IndicatorPlugin) handleMRURestore(mruData plugin.MRUData) (*plugin.QueryResult, error) { + var contextData indicatorContextData + if err := json.Unmarshal([]byte(mruData.ContextData), &contextData); err != nil { + return nil, fmt.Errorf("failed to parse context data: %w", err) + } + + // Find the plugin instance by ID + var pluginInstance *plugin.Instance + for _, instance := range plugin.GetPluginManager().GetPluginInstances() { + if instance.Metadata.Id == contextData.PluginID { + pluginInstance = instance + break + } + } + + if pluginInstance == nil { + return nil, fmt.Errorf("plugin no longer exists: %s", contextData.PluginID) + } + + // Check if trigger keyword still exists + triggerKeywords := pluginInstance.GetTriggerKeywords() + found := false + for _, keyword := range triggerKeywords { + if keyword == contextData.TriggerKeyword { + found = true + break + } + } + + if !found { + return nil, fmt.Errorf("trigger keyword no longer exists: %s", contextData.TriggerKeyword) + } + + result := &plugin.QueryResult{ + Id: uuid.NewString(), + Title: contextData.TriggerKeyword, + SubTitle: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(context.Background(), "plugin_indicator_activate_plugin"), pluginInstance.Metadata.Name), + Icon: mruData.Icon, + ContextData: mruData.ContextData, + Actions: []plugin.QueryResultAction{ + { + Name: "i18n:plugin_indicator_activate", + PreventHideAfterAction: true, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + i.api.ChangeQuery(ctx, common.PlainQuery{ + QueryType: plugin.QueryTypeInput, + QueryText: fmt.Sprintf("%s ", contextData.TriggerKeyword), + }) + }, + }, + }, + } + + return result, nil +} diff --git a/wox.core/plugin/system/sys.go b/wox.core/plugin/system/sys.go index 5315edbb..587881c2 100644 --- a/wox.core/plugin/system/sys.go +++ b/wox.core/plugin/system/sys.go @@ -2,6 +2,7 @@ package system import ( "context" + "encoding/json" "fmt" "os" "path" @@ -34,6 +35,10 @@ type SysCommand struct { Action func(ctx context.Context, actionContext plugin.ActionContext) } +type sysContextData struct { + CommandTitle string `json:"commandTitle"` +} + func (r *SysPlugin) GetMetadata() plugin.Metadata { return plugin.Metadata{ Id: "227f7d64-df08-4e35-ad05-98a26d540d06", @@ -55,6 +60,11 @@ func (r *SysPlugin) GetMetadata() plugin.Metadata { "Macos", "Linux", }, + Features: []plugin.MetadataFeature{ + { + Name: plugin.MetadataFeatureMRU, + }, + }, } } @@ -186,11 +196,17 @@ func (r *SysPlugin) Query(ctx context.Context, query plugin.Query) (results []pl } if isTitleMatch { + contextData := sysContextData{ + CommandTitle: command.Title, + } + contextDataJson, _ := json.Marshal(contextData) + results = append(results, plugin.QueryResult{ - Title: command.Title, - SubTitle: command.SubTitle, - Score: titleScore, - Icon: command.Icon, + Title: command.Title, + SubTitle: command.SubTitle, + Score: titleScore, + Icon: command.Icon, + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_sys_execute", @@ -200,6 +216,8 @@ func (r *SysPlugin) Query(ctx context.Context, query plugin.Query) (results []pl }, }) } + + r.api.OnMRURestore(ctx, r.handleMRURestore) } for _, instance := range plugin.GetPluginManager().GetPluginInstances() { @@ -236,3 +254,39 @@ func (r *SysPlugin) Query(ctx context.Context, query plugin.Query) (results []pl return } + +func (r *SysPlugin) handleMRURestore(mruData plugin.MRUData) (*plugin.QueryResult, error) { + var contextData sysContextData + if err := json.Unmarshal([]byte(mruData.ContextData), &contextData); err != nil { + return nil, fmt.Errorf("failed to parse context data: %w", err) + } + + // Find the command by title + var foundCommand *SysCommand + for _, command := range r.commands { + if command.Title == contextData.CommandTitle { + foundCommand = &command + break + } + } + + if foundCommand == nil { + return nil, fmt.Errorf("system command no longer exists: %s", contextData.CommandTitle) + } + + result := &plugin.QueryResult{ + Title: foundCommand.Title, + SubTitle: foundCommand.SubTitle, + Icon: mruData.Icon, + ContextData: mruData.ContextData, + Actions: []plugin.QueryResultAction{ + { + Name: "i18n:plugin_sys_execute", + Action: foundCommand.Action, + PreventHideAfterAction: foundCommand.PreventHideAfterAction, + }, + }, + } + + return result, nil +} diff --git a/wox.core/plugin/system/url.go b/wox.core/plugin/system/url.go index 80faae94..782fffdd 100644 --- a/wox.core/plugin/system/url.go +++ b/wox.core/plugin/system/url.go @@ -26,6 +26,12 @@ type UrlHistory struct { Title string } +type urlContextData struct { + Url string `json:"url"` + Title string `json:"title"` + Type string `json:"type"` // "history" or "direct" +} + type UrlPlugin struct { api plugin.API reg *regexp.Regexp @@ -53,6 +59,11 @@ func (r *UrlPlugin) GetMetadata() plugin.Metadata { "Macos", "Linux", }, + Features: []plugin.MetadataFeature{ + { + Name: plugin.MetadataFeatureMRU, + }, + }, } } @@ -60,6 +71,8 @@ func (r *UrlPlugin) Init(ctx context.Context, initParams plugin.InitParams) { r.api = initParams.API r.reg = r.getReg() r.recentUrls = r.loadRecentUrls(ctx) + + r.api.OnMRURestore(ctx, r.handleMRURestore) } func (r *UrlPlugin) loadRecentUrls(ctx context.Context) []UrlHistory { @@ -90,11 +103,19 @@ func (r *UrlPlugin) Query(ctx context.Context, query plugin.Query) (results []pl }) for _, history := range existingUrlHistory { + contextData := urlContextData{ + Url: history.Url, + Title: history.Title, + Type: "history", + } + contextDataJson, _ := json.Marshal(contextData) + results = append(results, plugin.QueryResult{ - Title: history.Url, - SubTitle: history.Title, - Score: 100, - Icon: history.Icon.Overlay(urlIcon, 0.4, 0.6, 0.6), + Title: history.Url, + SubTitle: history.Title, + Score: 100, + Icon: history.Icon.Overlay(urlIcon, 0.4, 0.6, 0.6), + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_url_open", @@ -119,11 +140,19 @@ func (r *UrlPlugin) Query(ctx context.Context, query plugin.Query) (results []pl } if len(r.reg.FindStringIndex(query.Search)) > 0 { + contextData := urlContextData{ + Url: query.Search, + Title: "", + Type: "direct", + } + contextDataJson, _ := json.Marshal(contextData) + results = append(results, plugin.QueryResult{ - Title: query.Search, - SubTitle: "i18n:plugin_url_open_in_browser", - Score: 100, - Icon: urlIcon, + Title: query.Search, + SubTitle: "i18n:plugin_url_open_in_browser", + Score: 100, + Icon: urlIcon, + ContextData: string(contextDataJson), Actions: []plugin.QueryResultAction{ { Name: "i18n:plugin_url_open", @@ -202,3 +231,77 @@ func (r *UrlPlugin) removeRecentUrl(ctx context.Context, url string) { r.api.SaveSetting(ctx, "recentUrls", string(urlsJson), false) } + +func (r *UrlPlugin) handleMRURestore(mruData plugin.MRUData) (*plugin.QueryResult, error) { + var contextData urlContextData + if err := json.Unmarshal([]byte(mruData.ContextData), &contextData); err != nil { + return nil, fmt.Errorf("failed to parse context data: %w", err) + } + + url := contextData.Url + if !strings.HasPrefix(url, "http") { + url = "https://" + url + } + + result := &plugin.QueryResult{ + Title: contextData.Url, + SubTitle: contextData.Title, + Icon: mruData.Icon, + ContextData: mruData.ContextData, + } + + if contextData.Type == "history" { + found := false + for _, history := range r.recentUrls { + if history.Url == contextData.Url { + found = true + result.SubTitle = history.Title + break + } + } + + if !found { + return nil, fmt.Errorf("URL no longer in history: %s", contextData.Url) + } + + result.Actions = []plugin.QueryResultAction{ + { + Name: "i18n:plugin_url_open", + Icon: plugin.OpenIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + openErr := shell.Open(url) + if openErr != nil { + r.api.Log(ctx, "Error opening URL", openErr.Error()) + } + }, + }, + { + Name: "i18n:plugin_url_remove", + Icon: plugin.TrashIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + r.removeRecentUrl(ctx, contextData.Url) + }, + }, + } + } else { + result.SubTitle = "i18n:plugin_url_open_in_browser" + result.Actions = []plugin.QueryResultAction{ + { + Name: "i18n:plugin_url_open", + Icon: urlIcon, + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + openErr := shell.Open(url) + if openErr != nil { + r.api.Log(ctx, "Error opening URL", openErr.Error()) + } else { + util.Go(ctx, "saveRecentUrl", func() { + r.saveRecentUrl(ctx, url) + }) + } + }, + }, + } + } + + return result, nil +} diff --git a/wox.core/resource/lang/en_US.json b/wox.core/resource/lang/en_US.json index 70f8a328..e8c0a678 100644 --- a/wox.core/resource/lang/en_US.json +++ b/wox.core/resource/lang/en_US.json @@ -9,10 +9,11 @@ "ui_selection_hotkey_tips": "Hotkeys to do actions on selected text or files", "ui_use_pinyin": "Use Pinyin", "ui_use_pinyin_tips": "When selected, Wox will convert Chinese into Pinyin", - "ui_last_query_mode": "Last Query Mode", - "ui_last_query_mode_tips": "Choose the default behavior when opening Wox", - "ui_last_query_mode_preserve": "Preserve last query", - "ui_last_query_mode_empty": "Always start with empty query", + "ui_query_mode": "Query Mode", + "ui_query_mode_tips": "Choose the default behavior when opening Wox", + "ui_query_mode_preserve": "Preserve last query", + "ui_query_mode_empty": "Always start with empty query", + "ui_query_mode_mru": "Show most recently used items", "ui_hide_on_lost_focus": "Hide on lost focus", "ui_hide_on_lost_focus_tips": "When selected, Wox will hide when it loses focus", "ui_hide_on_start": "Hide on start", @@ -386,6 +387,7 @@ "plugin_ai_chat_default_model_tooltip": "The default model to use for this command", "plugin_ai_chat_enable_fallback_search": "Fallback Search", "plugin_ai_chat_enable_fallback_search_tooltip": "When enabled, if the query result is empty, Wox will return a result of 'use AI Chat for %s'", + "plugin_app_open_failed_description": "Failed to open: %s", "plugin_ai_chat_fallback_search_chat_for": "Use AI Chat for %s", "plugin_ai_chat_start_chat": "Start Chat", "plugin_ai_chat_enable_auto_focus_to_chat_input": "Auto focus to chat input when open with query hotkey", diff --git a/wox.core/resource/lang/pt_BR.json b/wox.core/resource/lang/pt_BR.json index 9715eae6..7a5b8d9c 100644 --- a/wox.core/resource/lang/pt_BR.json +++ b/wox.core/resource/lang/pt_BR.json @@ -13,6 +13,9 @@ "ui_last_query_mode_tips": "Escolha o comportamento padrão ao abrir o Wox", "ui_last_query_mode_preserve": "Preservar última consulta", "ui_last_query_mode_empty": "Sempre começar com consulta vazia", + "ui_last_query_mode_mru": "Mostrar itens usados recentemente", + "ui_last_query_mode_preserve": "Preservar última consulta", + "ui_last_query_mode_empty": "Sempre começar com consulta vazia", "ui_hide_on_lost_focus": "Ocultar na perda do foco", "ui_hide_on_lost_focus_tips": "Quando selecionado, o Wox será ocultado na perda do foco", "ui_hide_on_start": "Ocultar ao iniciar", diff --git a/wox.core/resource/lang/ru_RU.json b/wox.core/resource/lang/ru_RU.json index f6032992..639f3fa0 100644 --- a/wox.core/resource/lang/ru_RU.json +++ b/wox.core/resource/lang/ru_RU.json @@ -13,6 +13,9 @@ "ui_last_query_mode_tips": "Выберите поведение по умолчанию при открытии Wox", "ui_last_query_mode_preserve": "Сохранить последний запрос", "ui_last_query_mode_empty": "Всегда начинать с пустого запроса", + "ui_last_query_mode_mru": "Показать недавно использованные элементы", + "ui_last_query_mode_preserve": "Сохранить последний запрос", + "ui_last_query_mode_empty": "Всегда начинать с пустого запроса", "ui_hide_on_lost_focus": "Скрывать при потере фокуса", "ui_hide_on_lost_focus_tips": "При выборе Wox будет скрываться при потере фокуса", "ui_hide_on_start": "Скрывать при запуске", diff --git a/wox.core/resource/lang/zh_CN.json b/wox.core/resource/lang/zh_CN.json index 98e22930..54a1aa5a 100644 --- a/wox.core/resource/lang/zh_CN.json +++ b/wox.core/resource/lang/zh_CN.json @@ -9,10 +9,11 @@ "ui_selection_hotkey_tips": "用于在选定的文本或文件上执行操作的快捷键", "ui_use_pinyin": "使用拼音", "ui_use_pinyin_tips": "搜索时,把中文转换为拼音", - "ui_last_query_mode": "上次查询模式", - "ui_last_query_mode_tips": "选择打开Wox时的默认行为", - "ui_last_query_mode_preserve": "保留上次查询", - "ui_last_query_mode_empty": "总是从空查询开始", + "ui_query_mode": "查询模式", + "ui_query_mode_tips": "选择打开Wox时的默认行为", + "ui_query_mode_preserve": "保留上次查询", + "ui_query_mode_empty": "总是从空查询开始", + "ui_query_mode_mru": "显示最近使用的项目", "ui_hide_on_lost_focus": "失去焦点时隐藏", "ui_hide_on_lost_focus_tips": "选中后,Wox失去焦点时将隐藏", "ui_hide_on_start": "启动时隐藏", @@ -386,6 +387,11 @@ "plugin_ai_chat_default_model_tooltip": "用于对话的默认模型", "plugin_ai_chat_enable_fallback_search": "回退搜索", "plugin_ai_chat_enable_fallback_search_tooltip": "当查询结果为空时,Wox 将返回一个 '使用 AI Chat 查询 %s' 的结果", + "plugin_app_open": "打开", + "plugin_app_open_containing_folder": "打开所在文件夹", + "plugin_app_copy_path": "复制路径", + "plugin_app_terminate": "终止进程", + "plugin_app_open_failed_description": "打开失败:%s", "plugin_ai_chat_fallback_search_chat_for": "使用 AI Chat 查询 %s", "plugin_ai_chat_start_chat": "开始对话", "plugin_ai_chat_enable_auto_focus_to_chat_input": "使用查询快捷键打开时自动聚焦到对话输入框", diff --git a/wox.core/setting/manager.go b/wox.core/setting/manager.go index 9efa90d4..9e94a1fd 100644 --- a/wox.core/setting/manager.go +++ b/wox.core/setting/manager.go @@ -4,10 +4,13 @@ import ( "context" "fmt" "sync" + "time" "wox/common" "wox/database" "wox/util" "wox/util/autostart" + + "github.com/samber/lo" ) var managerInstance *Manager @@ -16,6 +19,7 @@ var logger *util.Log type Manager struct { woxSetting *WoxSetting + mruManager *MRUManager } func GetSettingManager() *Manager { @@ -30,6 +34,7 @@ func GetSettingManager() *Manager { store := NewWoxSettingStore(db) managerInstance = &Manager{} managerInstance.woxSetting = NewWoxSetting(store) + managerInstance.mruManager = NewMRUManager(db) }) return managerInstance } @@ -77,14 +82,14 @@ func (m *Manager) GetWoxSetting(ctx context.Context) *WoxSetting { return m.woxSetting } -func (m *Manager) GetLatestQueryHistory(ctx context.Context, limit int) []common.PlainQuery { +func (m *Manager) GetLatestQueryHistory(ctx context.Context, limit int) []QueryHistory { histories := m.woxSetting.QueryHistories.Get() // Sort by timestamp descending and limit results - var result []common.PlainQuery + var result []QueryHistory count := 0 for i := len(histories) - 1; i >= 0 && count < limit; i-- { - result = append(result, histories[i].Query) + result = append(result, histories[i]) count++ } @@ -137,3 +142,64 @@ func (m *Manager) RemoveFavoriteResult(ctx context.Context, pluginId string, res favoriteResults.Delete(resultHash) m.woxSetting.FavoriteResults.Set(favoriteResults) } + +func (m *Manager) AddQueryHistory(ctx context.Context, query common.PlainQuery) { + histories := m.woxSetting.QueryHistories.Get() + newHistory := QueryHistory{ + Query: query, + Timestamp: util.GetSystemTimestamp(), + } + + // Remove duplicate if exists (same query text) + histories = lo.Filter(histories, func(item QueryHistory, index int) bool { + return !item.Query.IsEmpty() && item.Query.QueryText != query.QueryText + }) + + // Add new history at the end + histories = append(histories, newHistory) + + // Keep only the most recent 1000 entries + if len(histories) > 1000 { + histories = histories[len(histories)-1000:] + } + + m.woxSetting.QueryHistories.Set(histories) +} + +// MRU related methods + +func (m *Manager) AddMRUItem(ctx context.Context, item MRUItem) error { + return m.mruManager.AddMRUItem(ctx, item) +} + +func (m *Manager) GetMRUItems(ctx context.Context, limit int) ([]MRUItem, error) { + return m.mruManager.GetMRUItems(ctx, limit) +} + +func (m *Manager) RemoveMRUItem(ctx context.Context, pluginID, title, subTitle string) error { + return m.mruManager.RemoveMRUItem(ctx, pluginID, title, subTitle) +} + +func (m *Manager) CleanupOldMRUItems(ctx context.Context, keepCount int) error { + return m.mruManager.CleanupOldMRUItems(ctx, keepCount) +} + +// StartMRUCleanup starts a background goroutine to periodically clean up old MRU items +func (m *Manager) StartMRUCleanup(ctx context.Context) { + util.Go(ctx, "MRU cleanup", func() { + ticker := time.NewTicker(24 * time.Hour) // Clean up once per day + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Keep only the most recent 100 MRU items + if err := m.CleanupOldMRUItems(ctx, 100); err != nil { + util.GetLogger().Error(ctx, fmt.Sprintf("failed to cleanup old MRU items: %s", err.Error())) + } + case <-ctx.Done(): + return + } + } + }) +} diff --git a/wox.core/setting/mru.go b/wox.core/setting/mru.go new file mode 100644 index 00000000..2596ca8c --- /dev/null +++ b/wox.core/setting/mru.go @@ -0,0 +1,153 @@ +package setting + +import ( + "context" + "encoding/json" + "fmt" + "time" + "wox/common" + "wox/database" + "wox/util" + + "gorm.io/gorm" +) + +// MRUItem represents a Most Recently Used item +type MRUItem struct { + PluginID string `json:"pluginId"` + Title string `json:"title"` + SubTitle string `json:"subTitle"` + Icon common.WoxImage `json:"icon"` + ContextData string `json:"contextData"` + LastUsed int64 `json:"lastUsed"` + UseCount int `json:"useCount"` +} + +// MRUManager manages Most Recently Used items +type MRUManager struct { + db *gorm.DB +} + +// NewMRUManager creates a new MRU manager +func NewMRUManager(db *gorm.DB) *MRUManager { + return &MRUManager{db: db} +} + +// AddMRUItem adds or updates an MRU item +func (m *MRUManager) AddMRUItem(ctx context.Context, item MRUItem) error { + hash := NewResultHash(item.PluginID, item.Title, item.SubTitle) + + // Serialize icon to JSON + iconData, err := json.Marshal(item.Icon) + if err != nil { + return fmt.Errorf("failed to serialize icon: %w", err) + } + + now := time.Now() + timestamp := util.GetSystemTimestamp() + + // Check if record exists + var existingRecord database.MRURecord + err = m.db.Where("hash = ?", string(hash)).First(&existingRecord).Error + + if err == gorm.ErrRecordNotFound { + // Create new record + record := database.MRURecord{ + Hash: string(hash), + PluginID: item.PluginID, + Title: item.Title, + SubTitle: item.SubTitle, + Icon: string(iconData), + ContextData: item.ContextData, + LastUsed: timestamp, + UseCount: 1, + CreatedAt: now, + UpdatedAt: now, + } + return m.db.Create(&record).Error + } else if err != nil { + return fmt.Errorf("failed to query MRU record: %w", err) + } else { + // Update existing record + updates := map[string]interface{}{ + "last_used": timestamp, + "use_count": existingRecord.UseCount + 1, + "context_data": item.ContextData, // Update context data in case it changed + "icon": string(iconData), // Update icon in case it changed + "updated_at": now, + } + return m.db.Model(&existingRecord).Updates(updates).Error + } +} + +// GetMRUItems retrieves MRU items sorted by usage +func (m *MRUManager) GetMRUItems(ctx context.Context, limit int) ([]MRUItem, error) { + var records []database.MRURecord + + // Order by last_used DESC, then by use_count DESC for items with same last_used time + err := m.db.Order("last_used DESC, use_count DESC").Limit(limit).Find(&records).Error + if err != nil { + return nil, fmt.Errorf("failed to query MRU records: %w", err) + } + + items := make([]MRUItem, 0, len(records)) + for _, record := range records { + // Deserialize icon + var icon common.WoxImage + if err := json.Unmarshal([]byte(record.Icon), &icon); err != nil { + util.GetLogger().Warn(ctx, fmt.Sprintf("failed to deserialize icon for MRU item %s: %s", record.Hash, err.Error())) + icon = common.WoxImage{} // Use empty icon as fallback + } + + items = append(items, MRUItem{ + PluginID: record.PluginID, + Title: record.Title, + SubTitle: record.SubTitle, + Icon: icon, + ContextData: record.ContextData, + LastUsed: record.LastUsed, + UseCount: record.UseCount, + }) + } + + return items, nil +} + +// RemoveMRUItem removes an MRU item by hash +func (m *MRUManager) RemoveMRUItem(ctx context.Context, pluginID, title, subTitle string) error { + hash := NewResultHash(pluginID, title, subTitle) + result := m.db.Where("hash = ?", string(hash)).Delete(&database.MRURecord{}) + if result.Error != nil { + return fmt.Errorf("failed to remove MRU item: %w", result.Error) + } + + util.GetLogger().Debug(ctx, fmt.Sprintf("removed MRU item: %s", hash)) + return nil +} + +// CleanupOldMRUItems removes old MRU items to keep the database size manageable +func (m *MRUManager) CleanupOldMRUItems(ctx context.Context, keepCount int) error { + // Keep only the most recent keepCount items + subQuery := m.db.Model(&database.MRURecord{}). + Select("hash"). + Order("last_used DESC, use_count DESC"). + Limit(keepCount) + + result := m.db.Where("hash NOT IN (?)", subQuery).Delete(&database.MRURecord{}) + if result.Error != nil { + return fmt.Errorf("failed to cleanup old MRU items: %w", result.Error) + } + + if result.RowsAffected > 0 { + util.GetLogger().Info(ctx, fmt.Sprintf("cleaned up %d old MRU items", result.RowsAffected)) + } + + return nil +} + +// GetMRUCount returns the total number of MRU items +func (m *MRUManager) GetMRUCount(ctx context.Context) (int64, error) { + var count int64 + err := m.db.Model(&database.MRURecord{}).Count(&count).Error + return count, err +} diff --git a/wox.core/setting/wox_setting.go b/wox.core/setting/wox_setting.go index 34d50277..796c8d74 100644 --- a/wox.core/setting/wox_setting.go +++ b/wox.core/setting/wox_setting.go @@ -22,7 +22,7 @@ type WoxSetting struct { LangCode *WoxSettingValue[i18n.LangCode] QueryHotkeys *PlatformValue[[]QueryHotkey] QueryShortcuts *WoxSettingValue[[]QueryShortcut] - LastQueryMode *WoxSettingValue[LastQueryMode] + QueryMode *WoxSettingValue[QueryMode] ShowPosition *WoxSettingValue[PositionType] AIProviders *WoxSettingValue[[]AIProvider] EnableAutoBackup *WoxSettingValue[bool] @@ -49,7 +49,7 @@ type WoxSetting struct { ActionedResults *WoxSettingValue[*util.HashMap[ResultHash, []ActionedResult]] } -type LastQueryMode = string +type QueryMode = string type PositionType string @@ -60,8 +60,9 @@ const ( ) const ( - LastQueryModePreserve LastQueryMode = "preserve" // preserve last query and select all for quick modify - LastQueryModeEmpty LastQueryMode = "empty" // empty last query + QueryModePreserve QueryMode = "preserve" // preserve last query and select all for quick modify + QueryModeEmpty QueryMode = "empty" // empty last query + QueryModeMRU QueryMode = "mru" // show MRU (Most Recently Used) list ) const ( @@ -134,7 +135,7 @@ func NewWoxSetting(store *WoxSettingStore) *WoxSetting { LangCode: NewWoxSettingValueWithValidator(store, "LangCode", defaultLangCode, func(code i18n.LangCode) bool { return i18n.IsSupportedLangCode(string(code)) }), - LastQueryMode: NewWoxSettingValue(store, "LastQueryMode", LastQueryModeEmpty), + QueryMode: NewWoxSettingValue(store, "QueryMode", QueryModeEmpty), ShowPosition: NewWoxSettingValue(store, "ShowPosition", PositionTypeMouseScreen), AppWidth: NewWoxSettingValue(store, "AppWidth", 800), MaxResultCount: NewWoxSettingValue(store, "MaxResultCount", 10), diff --git a/wox.core/ui/dto/setting_dto.go b/wox.core/ui/dto/setting_dto.go index ebc3eebd..457bb36f 100644 --- a/wox.core/ui/dto/setting_dto.go +++ b/wox.core/ui/dto/setting_dto.go @@ -17,7 +17,7 @@ type WoxSettingDto struct { LangCode i18n.LangCode QueryHotkeys []setting.QueryHotkey QueryShortcuts []setting.QueryShortcut - LastQueryMode setting.LastQueryMode + QueryMode setting.QueryMode AIProviders []setting.AIProvider HttpProxyEnabled bool HttpProxyUrl string diff --git a/wox.core/ui/router.go b/wox.core/ui/router.go index 25f8c0cd..da45307f 100644 --- a/wox.core/ui/router.go +++ b/wox.core/ui/router.go @@ -88,6 +88,7 @@ var routers = map[string]func(w http.ResponseWriter, r *http.Request){ "/hotkey/available": handleHotkeyAvailable, "/query/icon": handleQueryIcon, "/query/ratio": handleQueryRatio, + "/query/mru": handleQueryMRU, "/deeplink": handleDeeplink, "/version": handleVersion, } @@ -484,7 +485,7 @@ func handleSettingWox(w http.ResponseWriter, r *http.Request) { settingDto.LangCode = woxSetting.LangCode.Get() settingDto.QueryHotkeys = woxSetting.QueryHotkeys.Get() settingDto.QueryShortcuts = woxSetting.QueryShortcuts.Get() - settingDto.LastQueryMode = woxSetting.LastQueryMode.Get() + settingDto.QueryMode = woxSetting.QueryMode.Get() settingDto.AIProviders = woxSetting.AIProviders.Get() settingDto.HttpProxyEnabled = woxSetting.HttpProxyEnabled.Get() settingDto.HttpProxyUrl = woxSetting.HttpProxyUrl.Get() @@ -537,8 +538,8 @@ func handleSettingWoxUpdate(w http.ResponseWriter, r *http.Request) { woxSetting.ShowTray.Set(kv.Value.(bool)) case "LangCode": woxSetting.LangCode.Set(i18n.LangCode(kv.Value.(string))) - case "LastQueryMode": - woxSetting.LastQueryMode.Set(setting.LastQueryMode(kv.Value.(string))) + case "QueryMode": + woxSetting.QueryMode.Set(setting.QueryMode(kv.Value.(string))) case "ShowPosition": woxSetting.ShowPosition.Set(setting.PositionType(kv.Value.(string))) case "EnableAutoBackup": @@ -1155,3 +1156,23 @@ func handlePluginDetail(w http.ResponseWriter, r *http.Request) { func handleVersion(w http.ResponseWriter, r *http.Request) { writeSuccessResponse(w, updater.CURRENT_VERSION) } + +func handleQueryMRU(w http.ResponseWriter, r *http.Request) { + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + writeErrorResponse(w, fmt.Sprintf("failed to read request body: %s", readErr.Error())) + return + } + + var ctx context.Context + traceIdResult := gjson.GetBytes(body, "traceId") + if traceIdResult.Exists() && traceIdResult.String() != "" { + ctx = util.NewTraceContextWith(traceIdResult.String()) + } else { + ctx = util.NewTraceContext() + } + + mruResults := plugin.GetPluginManager().QueryMRU(ctx) + logger.Info(ctx, fmt.Sprintf("found %d MRU results", len(mruResults))) + writeSuccessResponse(w, mruResults) +} diff --git a/wox.core/ui/ui_impl.go b/wox.core/ui/ui_impl.go index 98c7be48..c61f25b9 100644 --- a/wox.core/ui/ui_impl.go +++ b/wox.core/ui/ui_impl.go @@ -203,7 +203,7 @@ func getShowAppParams(ctx context.Context, showContext common.ShowContext) map[s "AutoFocusToChatInput": showContext.AutoFocusToChatInput, "Position": position, "QueryHistories": setting.GetSettingManager().GetLatestQueryHistory(ctx, 10), - "LastQueryMode": woxSetting.LastQueryMode.Get(), + "QueryMode": woxSetting.QueryMode.Get(), } } diff --git a/wox.ui.flutter/wox/lib/api/wox_api.dart b/wox.ui.flutter/wox/lib/api/wox_api.dart index cc2ee897..813806c0 100644 --- a/wox.ui.flutter/wox/lib/api/wox_api.dart +++ b/wox.ui.flutter/wox/lib/api/wox_api.dart @@ -183,6 +183,14 @@ class WoxApi { return await WoxHttpUtil.instance.postData>("/doctor/check", null); } + Future> queryMRU(String traceId) async { + final response = await WoxHttpUtil.instance.postData("/query/mru", {"traceId": traceId}); + if (response is List) { + return response.map((item) => WoxQueryResult.fromJson(item)).toList(); + } + return []; + } + Future getUserDataLocation() async { return await WoxHttpUtil.instance.postData("/setting/userdata/location", null); } diff --git a/wox.ui.flutter/wox/lib/controllers/wox_launcher_controller.dart b/wox.ui.flutter/wox/lib/controllers/wox_launcher_controller.dart index fb6e300e..c306e296 100644 --- a/wox.ui.flutter/wox/lib/controllers/wox_launcher_controller.dart +++ b/wox.ui.flutter/wox/lib/controllers/wox_launcher_controller.dart @@ -19,13 +19,14 @@ import 'package:wox/entity/wox_hotkey.dart'; import 'package:wox/entity/wox_image.dart'; import 'package:wox/entity/wox_preview.dart'; import 'package:wox/entity/wox_query.dart'; +import 'package:wox/enums/wox_query_mode_enum.dart'; import 'package:wox/entity/wox_setting.dart'; import 'package:wox/entity/wox_theme.dart'; import 'package:wox/entity/wox_toolbar.dart'; import 'package:wox/entity/wox_websocket_msg.dart'; import 'package:wox/enums/wox_direction_enum.dart'; import 'package:wox/enums/wox_image_type_enum.dart'; -import 'package:wox/enums/wox_last_query_mode_enum.dart'; + import 'package:wox/enums/wox_msg_method_enum.dart'; import 'package:wox/enums/wox_msg_type_enum.dart'; import 'package:wox/enums/wox_position_type_enum.dart'; @@ -78,7 +79,7 @@ class WoxLauncherController extends GetxController { var currentQueryHistoryIndex = 0; // query history index, used to navigate query history var refreshCounter = 0; - var lastQueryMode = WoxLastQueryModeEnum.WOX_LAST_QUERY_MODE_PRESERVE.code; + var lastQueryMode = WoxQueryModeEnum.WOX_QUERY_MODE_PRESERVE.code; final isInSettingView = false.obs; var positionBeforeOpenSetting = const Offset(0, 0); @@ -148,7 +149,6 @@ class WoxLauncherController extends GetxController { return; } - //ignore the results if the query id is not matched if (currentQuery.value.queryId != receivedResults.first.queryId) { Logger.instance.error(traceId, "query id is not matched, ignore the results"); return; @@ -215,7 +215,7 @@ class WoxLauncherController extends GetxController { Future showApp(String traceId, ShowAppParams params) async { if (currentQuery.value.queryType == WoxQueryTypeEnum.WOX_QUERY_TYPE_INPUT.code) { canArrowUpHistory = true; - if (lastQueryMode == WoxLastQueryModeEnum.WOX_LAST_QUERY_MODE_PRESERVE.code) { + if (lastQueryMode == WoxQueryModeEnum.WOX_QUERY_MODE_PRESERVE.code) { //skip the first one, because it's the current query currentQueryHistoryIndex = 0; } else { @@ -225,7 +225,15 @@ class WoxLauncherController extends GetxController { // update some properties to latest for later use latestQueryHistories.assignAll(params.queryHistories); - lastQueryMode = params.lastQueryMode; + lastQueryMode = params.queryMode; + + // Handle MRU mode + if (lastQueryMode == WoxQueryModeEnum.WOX_QUERY_MODE_MRU.code) { + // Clear current query and show MRU results + currentQuery.value = PlainQuery.emptyInput(); + queryBoxTextFieldController.clear(); + queryMRU(traceId); + } // Handle different position types // on linux, we need to show first and then set position or center it @@ -247,8 +255,10 @@ class WoxLauncherController extends GetxController { } Future hideApp(String traceId) async { - //clear query box text if query type is selection or last query mode is empty - if (currentQuery.value.queryType == WoxQueryTypeEnum.WOX_QUERY_TYPE_SELECTION.code || lastQueryMode == WoxLastQueryModeEnum.WOX_LAST_QUERY_MODE_EMPTY.code) { + //clear query box text if query type is selection or last query mode is empty or MRU + if (currentQuery.value.queryType == WoxQueryTypeEnum.WOX_QUERY_TYPE_SELECTION.code || + lastQueryMode == WoxQueryModeEnum.WOX_QUERY_MODE_EMPTY.code || + lastQueryMode == WoxQueryModeEnum.WOX_QUERY_MODE_MRU.code) { currentQuery.value = PlainQuery.emptyInput(); queryBoxTextFieldController.clear(); hideActionPanel(traceId); @@ -439,6 +449,24 @@ class WoxLauncherController extends GetxController { } } + Future queryMRU(String traceId) async { + clearQueryResults(traceId); + + var queryId = const UuidV4().generate(); + currentQuery.value = PlainQuery.emptyInput(); + currentQuery.value.queryId = queryId; + + try { + final results = await WoxApi.instance.queryMRU(traceId); + for (var result in results) { + result.queryId = queryId; + } + onReceivedQueryResults(traceId, results); + } catch (e) { + Logger.instance.error(traceId, "Failed to query MRU: $e"); + } + } + void onQueryChanged(String traceId, PlainQuery query, String changeReason, {bool moveCursorToEnd = false}) { Logger.instance.debug(traceId, "query changed: ${query.queryText}, reason: $changeReason"); @@ -469,7 +497,12 @@ class WoxLauncherController extends GetxController { updateResultPreviewWidthRatioOnQueryChanged(traceId, query); updateToolbarOnQueryChanged(traceId, query); if (query.isEmpty) { - clearQueryResults(traceId); + // Check if we should show MRU results when query is empty + if (lastQueryMode == WoxQueryModeEnum.WOX_QUERY_MODE_MRU.code) { + queryMRU(traceId); + } else { + clearQueryResults(traceId); + } return; } @@ -854,7 +887,7 @@ class WoxLauncherController extends GetxController { traceId, ShowAppParams( queryHistories: latestQueryHistories, - lastQueryMode: lastQueryMode, + queryMode: lastQueryMode, selectAll: true, position: Position( type: WoxPositionTypeEnum.POSITION_TYPE_LAST_LOCATION.code, diff --git a/wox.ui.flutter/wox/lib/entity/wox_query.dart b/wox.ui.flutter/wox/lib/entity/wox_query.dart index 50ee8619..3554c743 100644 --- a/wox.ui.flutter/wox/lib/entity/wox_query.dart +++ b/wox.ui.flutter/wox/lib/entity/wox_query.dart @@ -1,7 +1,7 @@ import 'package:wox/entity/wox_image.dart'; import 'package:wox/entity/wox_list_item.dart'; import 'package:wox/entity/wox_preview.dart'; -import 'package:wox/enums/wox_last_query_mode_enum.dart'; +import 'package:wox/enums/wox_query_mode_enum.dart'; import 'package:wox/enums/wox_position_type_enum.dart'; import 'package:wox/enums/wox_query_type_enum.dart'; import 'package:wox/enums/wox_selection_type_enum.dart'; @@ -262,10 +262,10 @@ class ShowAppParams { late bool selectAll; late Position position; late List queryHistories; - late WoxLastQueryMode lastQueryMode; + late WoxQueryMode queryMode; late bool autoFocusToChatInput; - ShowAppParams({required this.selectAll, required this.position, required this.queryHistories, required this.lastQueryMode, this.autoFocusToChatInput = false}); + ShowAppParams({required this.selectAll, required this.position, required this.queryHistories, required this.queryMode, this.autoFocusToChatInput = false}); ShowAppParams.fromJson(Map json) { selectAll = json['SelectAll']; @@ -274,13 +274,10 @@ class ShowAppParams { } queryHistories = []; if (json['QueryHistories'] != null) { - json['QueryHistories'].forEach((v) { - queryHistories.add(QueryHistory.fromJson(v)); - }); - } else { - queryHistories = []; + final List histories = json['QueryHistories']; + queryHistories = histories.map((v) => QueryHistory.fromJson(v)).toList(); } - lastQueryMode = json['LastQueryMode']; + queryMode = json['QueryMode'] ?? 'empty'; autoFocusToChatInput = json['AutoFocusToChatInput'] ?? false; } } diff --git a/wox.ui.flutter/wox/lib/entity/wox_setting.dart b/wox.ui.flutter/wox/lib/entity/wox_setting.dart index 330ef794..a2a0c0dd 100644 --- a/wox.ui.flutter/wox/lib/entity/wox_setting.dart +++ b/wox.ui.flutter/wox/lib/entity/wox_setting.dart @@ -12,7 +12,7 @@ class WoxSetting { late String langCode; late List queryHotkeys; late List queryShortcuts; - late String lastQueryMode; + late String queryMode; late String showPosition; late List aiProviders; late int appWidth; @@ -37,7 +37,7 @@ class WoxSetting { required this.langCode, required this.queryHotkeys, required this.queryShortcuts, - required this.lastQueryMode, + required this.queryMode, required this.showPosition, required this.aiProviders, required this.appWidth, @@ -81,7 +81,7 @@ class WoxSetting { queryShortcuts = []; } - lastQueryMode = json['LastQueryMode']; + queryMode = json['QueryMode'] ?? 'empty'; if (json['AIProviders'] != null) { aiProviders = []; @@ -116,7 +116,7 @@ class WoxSetting { data['LangCode'] = langCode; data['QueryHotkeys'] = queryHotkeys; data['QueryShortcuts'] = queryShortcuts; - data['LastQueryMode'] = lastQueryMode; + data['QueryMode'] = queryMode; data['ShowPosition'] = showPosition; data['AIProviders'] = aiProviders; data['AppWidth'] = appWidth; diff --git a/wox.ui.flutter/wox/lib/enums/wox_last_query_mode_enum.dart b/wox.ui.flutter/wox/lib/enums/wox_last_query_mode_enum.dart deleted file mode 100644 index 5ac22895..00000000 --- a/wox.ui.flutter/wox/lib/enums/wox_last_query_mode_enum.dart +++ /dev/null @@ -1,13 +0,0 @@ -typedef WoxLastQueryMode = String; - -enum WoxLastQueryModeEnum { - WOX_LAST_QUERY_MODE_PRESERVE("preserve", "preserve"), - WOX_LAST_QUERY_MODE_EMPTY("empty", "empty"); - - final String code; - final String value; - - const WoxLastQueryModeEnum(this.code, this.value); - - static String getValue(String code) => WoxLastQueryModeEnum.values.firstWhere((activity) => activity.code == code).value; -} diff --git a/wox.ui.flutter/wox/lib/enums/wox_query_mode_enum.dart b/wox.ui.flutter/wox/lib/enums/wox_query_mode_enum.dart new file mode 100644 index 00000000..8d11f561 --- /dev/null +++ b/wox.ui.flutter/wox/lib/enums/wox_query_mode_enum.dart @@ -0,0 +1,14 @@ +typedef WoxQueryMode = String; + +enum WoxQueryModeEnum { + WOX_QUERY_MODE_PRESERVE("preserve", "preserve"), + WOX_QUERY_MODE_EMPTY("empty", "empty"), + WOX_QUERY_MODE_MRU("mru", "mru"); + + final String code; + final String value; + + const WoxQueryModeEnum(this.code, this.value); + + static String getValue(String code) => WoxQueryModeEnum.values.firstWhere((activity) => activity.code == code).value; +} diff --git a/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_general_view.dart b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_general_view.dart index c5446c52..97ecee1c 100644 --- a/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_general_view.dart +++ b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_general_view.dart @@ -9,7 +9,7 @@ import 'package:wox/controllers/wox_launcher_controller.dart'; import 'package:wox/entity/setting/wox_plugin_setting_table.dart'; import 'package:wox/entity/wox_hotkey.dart'; import 'package:wox/entity/wox_lang.dart'; -import 'package:wox/enums/wox_last_query_mode_enum.dart'; +import 'package:wox/enums/wox_query_mode_enum.dart'; import 'package:wox/modules/setting/views/wox_setting_base.dart'; class WoxSettingGeneralView extends WoxSettingBaseView { @@ -115,26 +115,30 @@ class WoxSettingGeneralView extends WoxSettingBaseView { }), ), formField( - label: controller.tr("ui_last_query_mode"), - tips: controller.tr("ui_last_query_mode_tips"), + label: controller.tr("ui_query_mode"), + tips: controller.tr("ui_query_mode_tips"), child: Obx(() { return SizedBox( width: 250, child: ComboBox( items: [ ComboBoxItem( - value: WoxLastQueryModeEnum.WOX_LAST_QUERY_MODE_PRESERVE.code, - child: Text(controller.tr("ui_last_query_mode_preserve")), + value: WoxQueryModeEnum.WOX_QUERY_MODE_PRESERVE.code, + child: Text(controller.tr("ui_query_mode_preserve")), ), ComboBoxItem( - value: WoxLastQueryModeEnum.WOX_LAST_QUERY_MODE_EMPTY.code, - child: Text(controller.tr("ui_last_query_mode_empty")), + value: WoxQueryModeEnum.WOX_QUERY_MODE_EMPTY.code, + child: Text(controller.tr("ui_query_mode_empty")), + ), + ComboBoxItem( + value: WoxQueryModeEnum.WOX_QUERY_MODE_MRU.code, + child: Text(controller.tr("ui_query_mode_mru")), ), ], - value: controller.woxSetting.value.lastQueryMode, + value: controller.woxSetting.value.queryMode, onChanged: (v) { if (v != null) { - controller.updateConfig("LastQueryMode", v); + controller.updateConfig("QueryMode", v); } }, ),