diff --git a/README.md b/README.md index c7fa9a2..0fab696 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Settings rewrites `config.json` when it saves, so `//` comments in it are not ke ]} ], "recentCount": 15, + "showAppRecent": false, // a second submenu: the folders used most in this app "historyExclude": ["C:\\Windows\\*", "*\\node_modules\\*"], "actions": [ { "name": "Open &terminal here", "command": "wt.exe", "args": "-d \"{path}\"" }, @@ -103,7 +104,7 @@ Settings rewrites `config.json` when it saves, so `//` comments in it are not ke `&` marks the accelerator key in names. History lives in `history.json`, and errors are logged to `folderite.log`, both in the same folder. -Recent folders are ranked by how often and how recently you used them, with a boost for folders used in the app the menu is opened over. +Recent folders are ranked by how often and how recently you used them, and the list is the same in every app. With `showAppRecent` on, a second submenu lists the folders used most in the app the menu is opened over. ## How it works diff --git a/docs/building.md b/docs/building.md index ca15f1a..1e87a3a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -53,7 +53,8 @@ There are no tests for Explorer, the file dialogs or the third-party apps. Those - Elevated Explorer/dialog: fails gracefully unless Folderite runs elevated - Keyboard shortcut: opens the menu for Explorer (active tab), a file dialog, Directory Opus (focused side) and the desktop; elsewhere picking a folder opens a new Explorer window; does nothing while paused; a shortcut that's taken shows a warning in Settings - Favorite with subfolders: submenu lists subfolders (by name / newest first); the folder itself is the bold top item; a disconnected network folder doesn't freeze the menu -- Recent folders: folders used in the current app rank higher there +- Recent folders: the same list over Explorer, Directory Opus and a file dialog; a folder inside a zip file (Explorer) or a 7z file (Opus) is not recorded +- Recent in this app (when enabled): over Opus it is named **Recent in Directory Opus** and lists only folders used in Opus; over Notepad's Save dialog it is named after Notepad; not shown over the tray icon - Dialog start folder: off by default; when on, a new Open/Save dialog moves to the app's last folder and keeps the typed file name; a per-app folder wins; folder pickers work - Drives submenu (when enabled): lists drives with free space; a disconnected network drive doesn't hang the menu diff --git a/docs/config.md b/docs/config.md index b7051e4..a6523f9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -30,6 +30,7 @@ If you keep notes in comments, keep them in a copy of the file, or edit by hand | --- | --- | --- | --- | | `favorites` | list of [favorite entries](#favorites) | `[]` (see below) | The favorites section of the menu. | | `recentCount` | number | `15` | How many folders the **Recent** submenu lists. `0` hides the submenu. A negative number counts as `0`. Settings limits it to 0 to 50 (**Menu > Recent folders**). | +| `showAppRecent` | true or false | `false` | Add a **Recent in** submenu with the folders used most in the app the menu is opened over. It lists up to `recentCount` folders. | | `historyExclude` | list of text | `[]` | [Wildcard patterns](#history-exclusions) for folders that are never remembered. | | `actions` | list of [actions](#actions) | one action, **Open &terminal here** | Commands in the current folder's submenu. | | `disabledProcesses` | list of text | `[]` | Program names, without `.exe`, where Folderite does nothing. | @@ -46,7 +47,7 @@ Some defaults need a note: - **`favorites`.** A key that is missing means no favorites. On first run, Folderite creates the file with four: `&Home` (your user folder), `&Desktop`, `Do&wnloads` and `D&ocuments`, each written as a full path. - **`actions`.** A key that is missing gives the one default action, `Open &terminal here`, which runs `wt.exe` with `-d "{path}"`. To have no actions at all, write `"actions": []`. -Where each key appears in Settings: `favorites` on **Favorites**, `actions` on **Actions**, `recentCount`, `showOpenWindows`, `showClipboard`, `showDrives` and `enableOnDesktop` on **Menu**, `dialogLastFolder` and `dialogFolders` on **Dialogs**, `historyExclude` and `disabledProcesses` on **Exclusions**, and `hotkey` on **General**. See [Settings](settings.md). +Where each key appears in Settings: `favorites` on **Favorites**, `actions` on **Actions**, `recentCount`, `showAppRecent`, `showOpenWindows`, `showClipboard`, `showDrives` and `enableOnDesktop` on **Menu**, `dialogLastFolder` and `dialogFolders` on **Dialogs**, `historyExclude` and `disabledProcesses` on **Exclusions**, and `hotkey` on **General**. See [Settings](settings.md). ## Favorites @@ -207,6 +208,7 @@ Every key appears once. ]} ], "recentCount": 15, + "showAppRecent": false, // a second submenu: the folders used most in this app "historyExclude": ["C:\\Windows\\*", "*\\node_modules\\*"], "actions": [ { "name": "Open &terminal here", "command": "wt.exe", "args": "-d \"{path}\"" }, diff --git a/docs/dialogs.md b/docs/dialogs.md index ce17533..954aa05 100644 --- a/docs/dialogs.md +++ b/docs/dialogs.md @@ -20,7 +20,7 @@ Folderite reads the folder from the address bar of the dialog (the breadcrumb). - The menu header shows the folder the dialog is in, with **Copy path**, your [actions](settings.md#actions) and **Add to favorites**. - If the dialog is somewhere that is not a folder path, such as **This PC** or a library, there is no current folder. The menu still opens and navigation still works, but the current-folder submenu is missing, and so are the actions. -The folder you were in is also recorded in [history](settings.md#history) under the program's name, which is how **Recent** favors the folders you use in that program. +The folder you were in is also recorded in [history](settings.md#history) under the program's name. That is where [Recent folders for this app](menu.md#recent-folders-for-this-app) gets the folders you use in that program. ## Navigating and keeping your file name diff --git a/docs/how-it-works.md b/docs/how-it-works.md index d4f6d74..be130b7 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -133,7 +133,7 @@ For a click on a supported window: 1. The hook thread sees the middle-button press, finds the adapter, swallows the click and posts a trigger message. 2. The main thread resolves the window again with the click point and gets the adapter. (`App.OnTrigger`.) -3. `App.ShowFolderMenu` asks the adapter for the current folder, and records it in history. It collects the open windows from every adapter, the top recent folders, and the folder on the clipboard. +3. `App.ShowFolderMenu` asks the adapter for the current folder, and records it in history. It collects the open windows from every adapter, the top recent folders, and the folder on the clipboard. With `showAppRecent` on, it also gets the top folders for the app's process and a name for the submenu: the adapter's name, such as Directory Opus, or for a file dialog the program's file description. 4. `MenuComposer.Build` makes a menu tree from the config and this context. 5. `MenuBuilder.Show` shows it and waits for you to pick. 6. The chosen item's action runs after the menu is gone. For a folder, `App.Navigate` brings the target window to the front, calls the adapter's `Navigate`, and records the folder in history for that app. If `Navigate` fails, a tray balloon says so. @@ -162,13 +162,7 @@ A folder's score is its rank times a factor that depends on how long ago you las | Less than 7 days ago | 0.5 | | Longer ago | 0.25 | -So that the **Recent** list can favor the app you're in, the sort key is: - -```text -score(folder) + 3 * score(folder's use in this app) -``` - -where each `score` is rank times the factor for that last-used time. The factor `3` is `AppWeight`. A few uses in the current app outweigh a modest lead elsewhere, but a far more popular folder still wins. Ties go to the most recent visit. With no app (the tray icon), the second term is zero. +`Top` sorts by that score, with ties going to the most recent visit. It ignores the per-app numbers, so **Recent** is the same list everywhere. `TopInApp`, used for the **Recent in** submenu when `showAppRecent` is on, sorts only the folders used in one app, by the score of their use in that app, with ties going to the most recent visit in that app. The per-app numbers also give `LastInApp`, the start folder for **Start in the last folder used** (see [Open and Save dialogs](dialogs.md)). The rest of the rules: @@ -177,6 +171,7 @@ The rest of the rules: - At most 1000 folders are kept. If there are more, the lowest scores go first. - At most 10 apps are kept for each folder. - Folders that match a pattern in `historyExclude` are neither recorded nor returned. +- A place inside an archive, such as `C:\Downloads\tools.zip\bin`, is neither recorded nor loaded. Folderite looks for a part of the path with an archive extension (`.zip`, `.7z`, `.rar`, `.iso` and other common ones) and treats the path as inside an archive unless that part is a real folder. Only those parts are checked on disk. A real folder named `backup.zip` is still recorded, and a place in an archive that has since been deleted is still left out. - The menu asks for `recentCount + 1` folders, since one may be the current folder, which is left out. History is saved in `history.json`, written to a temporary file that then replaces the old one. The tray app saves it every 60 seconds if it changed, when Settings asks for it, and when it exits. diff --git a/docs/menu.md b/docs/menu.md index c2c19df..de767a7 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -25,6 +25,7 @@ Sections are separated by lines. A section that has nothing to show leaves out i | [Favorites](#favorites) | You have favorites | | [Open windows](#open-windows) | **Show open windows** is on, and another window is open on a folder | | [Recent](#recent-folders) | **Recent folders** is above 0 and there is a folder to list | +| [Recent in this app](#recent-folders-for-this-app) | **Show recent folders for this app** is on (off by default), and you have used a folder in the app the menu is over. It shares a section with **Recent**. | | [Drives](#drives) | **Show drives** is on (off by default) | | [Clipboard](#clipboard-folder) | **Show clipboard folder** is on, and the clipboard holds a folder path that is not the current folder | | [Settings… and Reload](#settings-and-reload) | Always | @@ -85,9 +86,9 @@ File dialogs, Blender and the desktop add nothing to this list. ### Recent folders -The **Recent** submenu lists the folders you use most, best first. It shows up to **Recent folders** entries (15 by default, 0 to 50 in Settings). It leaves out the folder you are in. A folder can appear in both **Open windows** and **Recent**. With 0, the section is hidden. +The **Recent** submenu lists the folders you use most, best first. It is the same list in every app, and over the tray icon. It shows up to **Recent folders** entries (15 by default, 0 to 50 in Settings). It leaves out the folder you are in. A folder can appear in both **Open windows** and **Recent**. With 0, the section is hidden. -Folderite learns folders without any action from you. It notes a folder when you move to it in Explorer, a file dialog, Directory Opus, Total Commander or XYplorer, when you bring such a window to the front, when you open the menu over it, and when you pick it from the menu. A repeat within 2 seconds counts once. Folders that match a pattern under **Exclusions**, **Don't remember these folders**, are never recorded. Use **History** in Settings to look at the list, remove a folder, or clear it. +Folderite learns folders without any action from you. It notes a folder when you move to it in Explorer, a file dialog, Directory Opus, Total Commander or XYplorer, when you bring such a window to the front, when you open the menu over it, and when you pick it from the menu. A repeat within 2 seconds counts once. Folders that match a pattern under **Exclusions**, **Don't remember these folders**, are never recorded. Places inside an archive are not recorded either, such as a zip file you browse into in Explorer or a 7z, RAR or ISO file in Directory Opus. Use **History** in Settings to look at the list, remove a folder, or clear it. #### How recent folders are ranked @@ -104,10 +105,14 @@ Each visit adds 1 to a folder's count. The count is then multiplied by how long The folder with the highest result comes first. A tie goes to the folder used most recently. -**The boost for the current app.** Folderite also counts the visits made in each app, using the program name (`explorer`, `notepad`, and so on). When you open the menu over an app, each folder's score gets three times its score in that app, worked out the same way. A few visits in that app outweigh a modest lead elsewhere, so over Notepad's Save dialog the folders you use with Notepad rise. A folder you use far more overall still wins. Opening the menu from the tray icon has no app, so there is no boost. - Folderite keeps at most 1,000 folders. When the total of all counts grows past 9,000, every count shrinks by 10 percent and folders that fall below 1 are dropped. History is saved to `history.json` every minute and when Folderite exits. See [Files and logs](files.md). +### Recent folders for this app + +Turn on **Show recent folders for this app** on the **Menu** page of Settings (`showAppRecent`) to get a second submenu under **Recent**, named for the app the menu is over: **Recent in Directory Opus**, **Recent in Explorer**, or for a file dialog the program's name, such as **Recent in Notepad**. It lists the folders you have used in that app, best first, so over Notepad's Save dialog you see the folders you save to from Notepad. + +Folderite counts the visits made in each app separately, by program name (`explorer`, `dopus`, `notepad`, and so on). This submenu ranks folders the same way as **Recent**, using only the visits made in that app. A folder you have never used in the app is not listed. It shows up to **Recent folders** entries, leaves out the folder you are in, and is hidden when there is nothing to list. Opening the menu from the tray icon has no app, so the submenu is not shown there. + ### Drives With **Show drives** on, a **Drives** submenu appears after **Recent**. It lists each drive as "Local Disk (C:)" or by its volume label, with the free space on the right, for example "12.3 GB free of 476 GB". It is read when you open the submenu, and a drive that does not answer in time is listed without sizes. @@ -129,7 +134,7 @@ Every named item has an accelerator key. While the menu is open, press the lette - In a name you write yourself, put `&` before the letter: `&Projects` is picked with **P**. Type `&&` for a plain ampersand. - When no item has the letter marked, items whose name starts with that letter match. This is how you reach recent folders, open windows, subfolders and drives, which carry no `&`. -- The built-in items use these letters: **C** for **Copy path**, **A** for **Add to favorites**, **R** for **Recent**, **V** for **Drives**, **L** for **Clipboard**, **S** for **Settings…**, **D** for **Reload**. The starter favorites use **H**, **D**, **W** and **O**. The starter action uses **T**. +- The built-in items use these letters: **C** for **Copy path**, **A** for **Add to favorites**, **R** for **Recent**, **I** for **Recent in** the app, **V** for **Drives**, **L** for **Clipboard**, **S** for **Settings…**, **D** for **Reload**. The starter favorites use **H**, **D**, **W** and **O**. The starter action uses **T**. - If one letter matches only one item in the open menu, it is picked at once. If it matches several, the first press highlights the next match after the current one, and each further press moves to the following match. Press **Enter** to pick the highlighted item. The arrow keys, **Enter** and **Esc** work as in any Windows menu. diff --git a/docs/settings.md b/docs/settings.md index 0225856..8b4c908 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -151,6 +151,7 @@ Which sections the folder menu has. The page heading is **Sections**. | Setting | Default | What it does | | --- | --- | --- | | **Recent folders** | 15 | A slider from 0 to 50. The number of folders in the **Recent** submenu. At 0 the submenu is hidden. | +| **Show recent folders for this app** | Off | Adds a second submenu under **Recent**, such as **Recent in Notepad**, with the folders you use most in the app the menu is opened over. It lists up to **Recent folders** entries. See [Recent folders for this app](menu.md#recent-folders-for-this-app). | | **Show open windows** | On | Adds an **Open windows** list with the folders open right now in Explorer and the other supported file managers, up to 10. | | **Show clipboard folder** | On | Adds a **Clipboard** item when the clipboard holds a folder path, unless it is the folder you are already in. | | **Show drives** | Off | Adds a **Drives** submenu with the free space on each drive. | @@ -201,7 +202,7 @@ Programs listed here are ignored: Folderite does not open its menu over them wit ## History -The folders Folderite has seen, ranked by how often and how recently you visit them. The **Recent** submenu uses the same ranking and adds a boost for folders used in the app the menu is opened over. This page reads `history.json`, not `config.json`, and its changes apply right away. The list refreshes each time you open the page. +The folders Folderite has seen, ranked by how often and how recently you visit them. The **Recent** submenu uses the same ranking. This page reads `history.json`, not `config.json`, and its changes apply right away. The list refreshes each time you open the page. Each row shows the folder name, its parent folder, and when you last used it (**just now**, **5 min ago**, **3 h ago**, **yesterday**, **12 days ago**, and a date after 30 days). Hover over a row for the full path. Under the list, a line shows how many folders are listed out of the total. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b2a47e2..5642f2a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -99,6 +99,11 @@ The reason is Windows' own text, for example that the file can't be found. Check - Folders matching a pattern in **Exclusions** are never recorded, and hidden if they were recorded earlier. See [History exclusions](config.md#history-exclusions). - Folderite records folders from windows it supports only, and not in an app on the disabled list. - You cleared the history on the **History** page. +- Places inside a zip, 7z, RAR, ISO or other archive file are not recorded, and ones recorded by older versions are dropped. + +### The Recent in submenu is missing + +It is off by default. Turn on **Settings > Menu > Show recent folders for this app**. It is not shown over the tray icon, and it is hidden in an app where you have not used a folder yet. ### The Drives submenu is missing diff --git a/site/index.html b/site/index.html index 6584898..0d90c6a 100644 --- a/site/index.html +++ b/site/index.html @@ -162,7 +162,7 @@

Open and Save dialogs

03

Recent folders, ranked by use

-

Folderite notes the folders you visit in any supported file manager and ranks them by how often and how recently you went there, the way zoxide does. Your everyday folders rise to the top of Recent without you curating anything, and folders you use in an app get a boost when you open the menu there.

+

Folderite notes the folders you visit in any supported file manager and ranks them by how often and how recently you went there, the way zoxide does. Your everyday folders rise to the top of Recent without you curating anything. Turn on a second submenu to see the folders you use most in the app you are in.

Keep folders out with patterns like C:\Windows\* or *\node_modules\* under Settings › Exclusions. Remove entries or clear the list under Settings › History.

diff --git a/src/Folderite/App.cs b/src/Folderite/App.cs index a76798d..3fe6923 100644 --- a/src/Folderite/App.cs +++ b/src/Folderite/App.cs @@ -308,21 +308,48 @@ private void OnTrigger(HWND root, int x, int y) private void ShowFolderMenu(ITargetAdapter? adapter, TargetWindow window, int x, int y) { string? current = adapter?.GetCurrentPath(window); - // Recent folders get a boost for the app the menu is opened over; the tray menu has no app. + // The folder counts as used in the app the menu is opened over; the tray menu has no app. string? app = adapter is null ? null : window.ProcessName; if (current is not null) _history.Record(current, app); _target = window; _targetAdapter = adapter; + bool appRecent = Cfg.ShowAppRecent && adapter is not null && !string.IsNullOrEmpty(app); var context = new MenuContext( current, Cfg.ShowOpenWindows ? OpenPaths() : [], - _history.Top(Cfg.RecentCount + 1, app), - Cfg.ShowClipboard ? Clipboard.GetFolderPath(_hwnd) : null); + _history.Top(Cfg.RecentCount + 1), + Cfg.ShowClipboard ? Clipboard.GetFolderPath(_hwnd) : null, + appRecent ? AppDisplayName(adapter!, window) : null, + appRecent ? _history.TopInApp(Cfg.RecentCount + 1, app!) : null); _menu.Show(MenuComposer.Build(Cfg, context, this), _hwnd, x, y); } + /// + /// The app's name in the "Recent in ..." submenu: a supported file manager's own name, or for a file dialog the + /// program's description from its version info, falling back to the process name. + /// + private static string AppDisplayName(ITargetAdapter adapter, TargetWindow window) + { + if (adapter is DesktopAdapter) return "Explorer"; + if (adapter is not FileDialogAdapter) return adapter.Name; + try + { + if (Win32.GetProcessImagePath(window.ProcessId) is { } exe) + { + var info = FileVersionInfo.GetVersionInfo(exe); + var name = string.IsNullOrWhiteSpace(info.FileDescription) ? info.ProductName : info.FileDescription; + if (!string.IsNullOrWhiteSpace(name)) return name.Trim(); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) + { + // No version info: use the process name. + } + return window.ProcessName; + } + private List OpenPaths() { var paths = new List(); diff --git a/src/Folderite/Config/AppConfig.cs b/src/Folderite/Config/AppConfig.cs index 8594696..8479682 100644 --- a/src/Folderite/Config/AppConfig.cs +++ b/src/Folderite/Config/AppConfig.cs @@ -10,6 +10,9 @@ public sealed class AppConfig /// Number of recent folders shown in the Recent submenu. public int RecentCount { get; set; } = 15; + /// Add a second submenu with the folders used most in the app the menu is opened over. + public bool ShowAppRecent { get; set; } + /// Wildcard patterns (* and ?) of paths never recorded in history, e.g. "C:\\Windows\\*". public List HistoryExclude { get; set; } = []; diff --git a/src/Folderite/History/FrecencyStore.cs b/src/Folderite/History/FrecencyStore.cs index 5283d71..d9ed737 100644 --- a/src/Folderite/History/FrecencyStore.cs +++ b/src/Folderite/History/FrecencyStore.cs @@ -13,13 +13,17 @@ public sealed class FrecencyStore private const double MaxTotalRank = 9000; private const int MaxAppsPerEntry = 10; + private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2); + /// - /// How much a folder's use in the asked-about app counts relative to its use overall. At 3, a few uses in the app - /// outweigh a modest lead elsewhere, but a far more popular folder still wins. + /// File types file managers open as folders (Directory Opus, and Explorer for zip). A path through one of these is + /// a place inside an archive, not a folder, so it is never recorded. /// - private const double AppWeight = 3; - - private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2); + private static readonly HashSet ArchiveExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".tbz2", ".xz", ".txz", ".zst", ".lz", ".lzma", + ".cab", ".iso", ".wim", ".lzh", ".lha", ".arj", ".jar", ".cbz", ".cbr", + }; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -61,7 +65,7 @@ public void Load() foreach (var e in file?.Entries ?? []) { var path = Normalize(e?.Path); - if (path is null || e is null || !double.IsFinite(e.Rank) || e.Rank <= 0) + if (path is null || e is null || !double.IsFinite(e.Rank) || e.Rank <= 0 || IsInArchive(path)) continue; loaded[path] = new Entry { Path = path, Rank = e.Rank, LastAccess = e.LastAccess, Apps = LoadApps(e.Apps) }; } @@ -134,12 +138,13 @@ public void Save() /// /// Records a visit to a folder. Repeats within 2 seconds are ignored. When names the /// process the folder was used in (e.g. "notepad"), that app's use of the folder is counted too. The app use has - /// its own 2-second debounce, so a repeat in one signal never hides a fresh one in the other. + /// its own 2-second debounce, so a repeat in one signal never hides a fresh one in the other. Places inside archives + /// (a zip file opened as a folder) are ignored. /// public void Record(string path, string? app = null) { var key = Normalize(path); - if (key is null) + if (key is null || IsInArchive(key)) return; var appKey = NormalizeApp(app); @@ -221,22 +226,18 @@ public void SetExcludes(IEnumerable patterns) lock (_lock) _excludes = list; } - /// - /// Returns up to folders ordered by frecency. Does not touch the disk. - /// With , a folder's use in that app adds to its score, so the app's own folders float up. - /// - public IReadOnlyList Top(int count, string? app = null) + /// Returns up to folders ordered by frecency over all apps. Does not touch the disk. + public IReadOnlyList Top(int count) { if (count <= 0) return []; - var appKey = NormalizeApp(app); lock (_lock) { var now = _time.GetUtcNow(); return _entries.Values .Where(e => !IsExcluded(e.Path)) - .OrderByDescending(e => Score(e, now) + (appKey is null ? 0 : AppWeight * AppScore(e, appKey, now))) + .OrderByDescending(e => Score(e, now)) .ThenByDescending(e => e.LastAccess) .Take(count) .Select(e => e.Path) @@ -244,6 +245,30 @@ public IReadOnlyList Top(int count, string? app = null) } } + /// + /// Returns up to folders used in , ordered by frecency counting only + /// the visits made in that app. Folders never used there are left out. Does not touch the disk. + /// + public IReadOnlyList TopInApp(int count, string app) + { + var appKey = NormalizeApp(app); + if (count <= 0 || appKey is null) + return []; + + lock (_lock) + { + var now = _time.GetUtcNow(); + return _entries.Values + .Where(e => e.Apps is not null && e.Apps.ContainsKey(appKey) && !IsExcluded(e.Path)) + .Select(e => (e.Path, Use: e.Apps![appKey])) + .OrderByDescending(x => Score(x.Use.Rank, x.Use.LastAccess, now)) + .ThenByDescending(x => x.Use.LastAccess) + .Take(count) + .Select(x => x.Path) + .ToList(); + } + } + /// The non-excluded folder most recently used in , or null if there is none. public string? LastInApp(string app) { @@ -310,8 +335,24 @@ private bool IsExcluded(string path) private static double Score(Entry e, DateTimeOffset now) => Score(e.Rank, e.LastAccess, now); - private static double AppScore(Entry e, string app, DateTimeOffset now) => - e.Apps is not null && e.Apps.TryGetValue(app, out var use) ? Score(use.Rank, use.LastAccess, now) : 0; + /// + /// True when the path, or a folder above it, is an archive, as in "C:\Downloads\tools.zip\bin". A part with an archive + /// extension counts unless it is a real folder, so a folder called "x.zip" is still recorded, and a place in an + /// archive that has since been deleted still counts. Only those parts are checked on disk. + /// + internal static bool IsInArchive(string path) + { + var rootLength = Path.GetPathRoot(path)?.Length ?? 0; + for (var i = rootLength; i <= path.Length; i++) + { + if (i < path.Length && path[i] != Path.DirectorySeparatorChar) + continue; + var prefix = path[..i]; + if (prefix.Length > rootLength && ArchiveExtensions.Contains(Path.GetExtension(prefix)) && !Directory.Exists(prefix)) + return true; + } + return false; + } private static double Score(double rank, DateTimeOffset lastAccess, DateTimeOffset now) { diff --git a/src/Folderite/Menu/MenuComposer.cs b/src/Folderite/Menu/MenuComposer.cs index e8f2c48..fc713d6 100644 --- a/src/Folderite/Menu/MenuComposer.cs +++ b/src/Folderite/Menu/MenuComposer.cs @@ -3,11 +3,15 @@ namespace Folderite.Menu; /// What the menu needs to know about the moment it was opened. +/// Name of the app the menu is opened over, as the per-app recent submenu shows it; null for none. +/// The folders used most in that app. internal sealed record MenuContext( string? CurrentPath, IReadOnlyList OpenPaths, IReadOnlyList RecentPaths, - string? ClipboardPath); + string? ClipboardPath, + string? AppName = null, + IReadOnlyList? AppRecentPaths = null); /// Side effects the menu items trigger; implemented by App. internal interface IMenuCommands @@ -68,11 +72,19 @@ public static IReadOnlyList Build(AppConfig config, MenuContext ctx, I } } - var recent = Distinct(ctx.RecentPaths, current).Take(Math.Max(0, config.RecentCount)).ToList(); - if (recent.Count > 0) - { + int recentCount = Math.Max(0, config.RecentCount); + var recent = Distinct(ctx.RecentPaths, current).Take(recentCount).ToList(); + var appRecent = config.ShowAppRecent && !string.IsNullOrWhiteSpace(ctx.AppName) + ? Distinct(ctx.AppRecentPaths ?? [], current).Take(recentCount).ToList() + : []; + if (recent.Count > 0 || appRecent.Count > 0) nodes.Add(MenuSeparator.Instance); + if (recent.Count > 0) nodes.Add(new SubMenu("&Recent", recent.Select(p => (MenuNode)FolderItem(p, cmd, showParent: true)).ToList())); + if (appRecent.Count > 0) + { + nodes.Add(new SubMenu("Recent &in " + Escape(ctx.AppName!.Trim()), + appRecent.Select(p => (MenuNode)FolderItem(p, cmd, showParent: true)).ToList())); } if (config.ShowDrives) diff --git a/src/Folderite/Settings/Pages/MenuPage.xaml b/src/Folderite/Settings/Pages/MenuPage.xaml index 31014af..b9ff6b8 100644 --- a/src/Folderite/Settings/Pages/MenuPage.xaml +++ b/src/Folderite/Settings/Pages/MenuPage.xaml @@ -18,6 +18,11 @@ + + + + diff --git a/src/Folderite/Settings/ViewModels/SettingsVm.cs b/src/Folderite/Settings/ViewModels/SettingsVm.cs index fe0d514..1ca1ab3 100644 --- a/src/Folderite/Settings/ViewModels/SettingsVm.cs +++ b/src/Folderite/Settings/ViewModels/SettingsVm.cs @@ -66,6 +66,9 @@ public SettingsVm(ConfigStore store, Action post, TimeProvider? time = n [ObservableProperty] private bool showDrives; + [ObservableProperty] + private bool showAppRecent; + [ObservableProperty] private bool dialogLastFolder; @@ -102,6 +105,7 @@ public bool HasPendingSave partial void OnEnableOnDesktopChanged(bool value) => ScheduleSave(); partial void OnHotkeyChanged(string value) => ScheduleSave(); partial void OnShowDrivesChanged(bool value) => ScheduleSave(); + partial void OnShowAppRecentChanged(bool value) => ScheduleSave(); partial void OnDialogLastFolderChanged(bool value) => ScheduleSave(); partial void OnSelectedActionChanged(ActionVm? value) => RefreshActionCommands(); @@ -125,6 +129,7 @@ public void Load() EnableOnDesktop = _baseline.EnableOnDesktop; Hotkey = _baseline.Hotkey ?? ""; ShowDrives = _baseline.ShowDrives; + ShowAppRecent = _baseline.ShowAppRecent; DialogLastFolder = _baseline.DialogLastFolder; LoadError = _store.LastError; @@ -174,6 +179,7 @@ public AppConfig BuildConfig() config.DisabledProcesses = Clean(DisabledProcesses); config.Hotkey = Hotkey.Trim(); config.ShowDrives = ShowDrives; + config.ShowAppRecent = ShowAppRecent; config.DialogLastFolder = DialogLastFolder; config.DialogFolders = [.. DialogFolders.Select(d => d.ToConfig()).Where(d => d.App.Length > 0 && d.Path.Length > 0)]; return config; diff --git a/tests/Folderite.Tests/FrecencyStoreTests.cs b/tests/Folderite.Tests/FrecencyStoreTests.cs index 0a97dea..ede0f22 100644 --- a/tests/Folderite.Tests/FrecencyStoreTests.cs +++ b/tests/Folderite.Tests/FrecencyStoreTests.cs @@ -409,76 +409,127 @@ public void Clear_RemovesEverythingAndMarksDirty() Assert.Empty(store.Top(10)); } - // Scores at age < 1 h are rank x 4. InBlender: global 3 (12) + 3 x app 3 (36) = 48 with the app, 12 without. - // Elsewhere: global 4 (16) loses to 48 but beats 12; global 20 (80) beats 48 even with the app. [Fact] - public void Top_WithApp_FolderUsedInThatAppBeatsSlightlyMorePopularFolder() + public void Top_IgnoresAppUse() { var store = NewStore(); RecordTimes(store, @"C:\Elsewhere", 4); RecordTimes(store, @"C:\InBlender", 3, "blender"); Assert.Equal(new[] { @"C:\Elsewhere", @"C:\InBlender" }, store.Top(2)); - Assert.Equal(new[] { @"C:\InBlender", @"C:\Elsewhere" }, store.Top(2, "blender")); } [Fact] - public void Top_WithApp_MuchMorePopularFolderStillWins() + public void TopInApp_ListsOnlyFoldersUsedInThatApp() { var store = NewStore(); RecordTimes(store, @"C:\Elsewhere", 20); RecordTimes(store, @"C:\InBlender", 3, "blender"); + store.Record(@"C:\InNotepad", "notepad"); - Assert.Equal(new[] { @"C:\Elsewhere", @"C:\InBlender" }, store.Top(2, "blender")); + Assert.Equal(new[] { @"C:\InBlender" }, store.TopInApp(10, "blender")); + Assert.Equal(new[] { @"C:\InNotepad" }, store.TopInApp(10, "notepad")); + Assert.Empty(store.TopInApp(10, "zbrush")); } [Fact] - public void Top_WithOtherApp_IgnoresUseInDifferentApp() + public void TopInApp_RanksByUseInThatAppNotOverall() { var store = NewStore(); - RecordTimes(store, @"C:\Elsewhere", 4); - RecordTimes(store, @"C:\InBlender", 3, "blender"); + RecordTimes(store, @"C:\PopularElsewhere", 10); + Advance(TimeSpan.FromSeconds(3)); + store.Record(@"C:\PopularElsewhere", "blender"); + Advance(TimeSpan.FromSeconds(3)); + RecordTimes(store, @"C:\BlenderOften", 3, "blender"); - Assert.Equal(new[] { @"C:\Elsewhere", @"C:\InBlender" }, store.Top(2, "notepad")); + Assert.Equal(new[] { @"C:\PopularElsewhere", @"C:\BlenderOften" }, store.Top(2)); + Assert.Equal(new[] { @"C:\BlenderOften", @"C:\PopularElsewhere" }, store.TopInApp(2, "blender")); } [Fact] - public void Top_WithoutApp_MatchesGlobalOrderEvenWhenAppDataExists() + public void TopInApp_AppNameIsCaseAndExeInsensitive() { var store = NewStore(); - RecordTimes(store, @"C:\Elsewhere", 4); - RecordTimes(store, @"C:\InBlender", 3, "blender"); - var expected = new[] { @"C:\Elsewhere", @"C:\InBlender" }; + RecordTimes(store, @"C:\InBlender", 3, "Blender.EXE"); - Assert.Equal(expected, store.Top(2)); - Assert.Equal(expected, store.Top(2, null)); - Assert.Equal(expected, store.Top(2, "")); - Assert.Equal(expected, store.Top(2, " ")); + Assert.Equal(new[] { @"C:\InBlender" }, store.TopInApp(1, "blender")); + Assert.Equal(new[] { @"C:\InBlender" }, store.TopInApp(1, "BLENDER.exe")); } [Fact] - public void Top_WithApp_AppNameIsCaseAndExeInsensitive() + public void TopInApp_BlankAppOrZeroCount_IsEmpty() { var store = NewStore(); - RecordTimes(store, @"C:\Elsewhere", 4); - RecordTimes(store, @"C:\InBlender", 3, "Blender.EXE"); + store.Record(@"C:\A", "blender"); - Assert.Equal(@"C:\InBlender", store.Top(1, "blender")[0]); - Assert.Equal(@"C:\InBlender", store.Top(1, "BLENDER.exe")[0]); + Assert.Empty(store.TopInApp(10, "")); + Assert.Empty(store.TopInApp(10, " ")); + Assert.Empty(store.TopInApp(0, "blender")); } [Fact] - public void Top_WithApp_TiesGoToMostRecentlyAccessed() + public void TopInApp_SkipsExcludedPaths() { - // Identical combined scores (same ranks, same time bucket); only the global LastAccess differs. - var appUse = new[] { ("blender", 1.0, Start - TimeSpan.FromMinutes(20)) }; + var store = NewStore(); + store.Record(@"C:\Keep", "blender"); + store.Record(@"C:\Temp\Drop", "blender"); + store.SetExcludes([@"C:\Temp\*"]); + + Assert.Equal(new[] { @"C:\Keep" }, store.TopInApp(10, "blender")); + } + + [Fact] + public void TopInApp_TiesGoToMostRecentUseInThatApp() + { + // Same app rank and time bucket; only the app's LastAccess differs, and it runs the other way to the global one. SeedApps( - (@"C:\Older", 2, Start - TimeSpan.FromMinutes(10), appUse), - (@"C:\Newer", 2, Start - TimeSpan.FromMinutes(5), appUse)); + (@"C:\Older", 2, Start - TimeSpan.FromMinutes(5), [("blender", 1.0, Start - TimeSpan.FromMinutes(20))]), + (@"C:\Newer", 2, Start - TimeSpan.FromMinutes(10), [("blender", 1.0, Start - TimeSpan.FromMinutes(15))])); + var store = NewStore(); + store.Load(); + + Assert.Equal(new[] { @"C:\Newer", @"C:\Older" }, store.TopInApp(2, "blender")); + } + + [Fact] + public void Record_PlaceInsideArchive_IsIgnored() + { + var zip = Path.Combine(_temp.Root, "tools.zip"); + File.WriteAllBytes(zip, [0x50, 0x4B, 0x05, 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + var store = NewStore(); + store.Record(zip); + store.Record(Path.Combine(zip, "bin"), "dopus"); + store.Record(Path.Combine(zip, "bin", "x64")); + + Assert.Empty(store.Top(10)); + Assert.Empty(store.TopInApp(10, "dopus")); + Assert.False(store.IsDirty); + } + + [Fact] + public void Record_FolderNamedLikeAnArchive_IsRecorded() + { + var folder = Path.Combine(_temp.Root, "backup.zip"); + Directory.CreateDirectory(Path.Combine(folder, "inner")); + var store = NewStore(); + store.Record(folder); + Advance(TimeSpan.FromSeconds(3)); + store.Record(Path.Combine(folder, "inner")); + + Assert.Equal(2, store.Top(10).Count); + } + + [Fact] + public void Load_DropsPlacesInsideArchives() + { + var zip = Path.Combine(_temp.Root, "tools.7z"); + File.WriteAllText(zip, "not really an archive"); + var deleted = Path.Combine(_temp.Root, "gone.zip"); // the archive was deleted after the visit + Seed((Path.Combine(zip, "bin"), 3, Start), (Path.Combine(deleted, "src"), 2, Start), (@"C:\Real", 1, Start)); var store = NewStore(); store.Load(); - Assert.Equal(new[] { @"C:\Newer", @"C:\Older" }, store.Top(2, "blender")); + Assert.Equal(new[] { @"C:\Real" }, store.Top(10)); } [Fact] @@ -707,8 +758,8 @@ public void SaveThenLoad_RoundTripsAppData_AsVersion2() var reloaded = NewStore(); reloaded.Load(); - Assert.Equal(store.Top(10, "blender"), reloaded.Top(10, "blender")); - Assert.Equal(new[] { @"C:\InBlender", @"C:\Elsewhere" }, reloaded.Top(2, "blender")); + Assert.Equal(store.Top(10), reloaded.Top(10)); + Assert.Equal(new[] { @"C:\InBlender" }, reloaded.TopInApp(10, "blender")); Assert.Equal(@"C:\InBlender", reloaded.LastInApp("notepad")); Assert.False(reloaded.IsDirty); } @@ -721,7 +772,7 @@ public void Load_Version1FileWithoutApps_Loads() store.Load(); Assert.Equal(new[] { @"C:\A", @"C:\B" }, store.Top(10)); - Assert.Equal(new[] { @"C:\A", @"C:\B" }, store.Top(10, "blender")); + Assert.Empty(store.TopInApp(10, "blender")); Assert.Null(store.LastInApp("blender")); } @@ -757,7 +808,7 @@ public void Remove_DropsAppData() store.Remove(@"C:\A"); Assert.Null(store.LastInApp("blender")); - Assert.Empty(store.Top(10, "blender")); + Assert.Empty(store.TopInApp(10, "blender")); // Recording it afresh starts from zero: no app use is left over from before. Advance(TimeSpan.FromSeconds(3)); @@ -773,6 +824,6 @@ public void Clear_DropsAppData() store.Clear(); Assert.Null(store.LastInApp("blender")); - Assert.Empty(store.Top(10, "blender")); + Assert.Empty(store.TopInApp(10, "blender")); } } diff --git a/tests/Folderite.Tests/MenuComposerTests.cs b/tests/Folderite.Tests/MenuComposerTests.cs index 41c9a55..ce4a4b4 100644 --- a/tests/Folderite.Tests/MenuComposerTests.cs +++ b/tests/Folderite.Tests/MenuComposerTests.cs @@ -20,8 +20,10 @@ private static MenuContext Ctx( string? current = null, IEnumerable? open = null, IEnumerable? recent = null, - string? clipboard = null) => - new(current, (open ?? []).ToList(), (recent ?? []).ToList(), clipboard); + string? clipboard = null, + string? appName = null, + IEnumerable? appRecent = null) => + new(current, (open ?? []).ToList(), (recent ?? []).ToList(), clipboard, appName, appRecent?.ToList()); private static IReadOnlyList Build(AppConfig config, MenuContext ctx, IMenuCommands? cmd = null) => MenuComposer.Build(config, ctx, cmd ?? new FakeCommands()); @@ -363,6 +365,73 @@ public void Recent_ItemTextIsNameAndDetailIsParent() Assert.Equal(@"C:\Work", item.Detail); } + [Fact] + public void AppRecent_OffByDefault_NotShown() + { + var nodes = Build(Config(), Ctx(recent: [@"C:\A"], appName: "Notepad", appRecent: [@"C:\B"])); + + Assert.DoesNotContain(nodes.OfType(), s => s.Text.StartsWith("Recent &in")); + } + + [Fact] + public void AppRecent_WhenOn_FollowsRecentInTheSameSection() + { + var cmd = new FakeCommands(); + var config = Config(); + config.ShowAppRecent = true; + + var nodes = Build(config, Ctx(recent: [@"C:\A"], appName: "Directory Opus", appRecent: [@"C:\Work\B"]), cmd); + + int recent = nodes.ToList().FindIndex(n => n is SubMenu { Text: "&Recent" }); + int app = nodes.ToList().FindIndex(n => n is SubMenu { Text: "Recent &in Directory Opus" }); + Assert.Equal(recent + 1, app); + var item = Assert.IsType(Assert.Single(((SubMenu)nodes[app]).Children)); + Assert.Equal("B", item.Text); + Assert.Equal(@"C:\Work", item.Detail); + item.OnInvoke(); + Assert.Equal(new[] { @"C:\Work\B" }, cmd.Navigated); + } + + [Fact] + public void AppRecent_ShownEvenWhenRecentIsEmpty_WithItsOwnSeparator() + { + var config = Config(); + config.ShowAppRecent = true; + + var nodes = Build(config, Ctx(appName: "Notepad", appRecent: [@"C:\B"])); + + Assert.DoesNotContain(nodes.OfType(), s => s.Text == "&Recent"); + int app = nodes.ToList().FindIndex(n => n is SubMenu { Text: "Recent &in Notepad" }); + Assert.IsType(nodes[app - 1]); + } + + [Fact] + public void AppRecent_ExcludesCurrentPath_RespectsRecentCount_EscapesAppName() + { + var config = Config(); + config.ShowAppRecent = true; + config.RecentCount = 2; + + var nodes = Build(config, Ctx(Current, appName: "R&D Tool", appRecent: [Current, @"C:\A", @"C:\B", @"C:\C"])); + + var app = nodes.OfType().Single(s => s.Text == "Recent &in R&&D Tool"); + Assert.Equal(new[] { @"C:\A", @"C:\B" }, app.Children.Cast().Select(i => i.IconPath)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void AppRecent_NoAppOrNothingUsedThere_NotShown(string? appName) + { + var config = Config(); + config.ShowAppRecent = true; + + Assert.DoesNotContain(Build(config, Ctx(appName: appName, appRecent: [@"C:\B"])).OfType(), + s => s.Text.StartsWith("Recent &in")); + Assert.DoesNotContain(Build(config, Ctx(appName: "Notepad", appRecent: [])).OfType(), + s => s.Text.StartsWith("Recent &in")); + } + [Fact] public void Clipboard_ShownWhenDifferentFromCurrent_InvokingNavigates() { diff --git a/tests/Folderite.Tests/SettingsViewModelTests.cs b/tests/Folderite.Tests/SettingsViewModelTests.cs index 1cc2cb4..5a5bf58 100644 --- a/tests/Folderite.Tests/SettingsViewModelTests.cs +++ b/tests/Folderite.Tests/SettingsViewModelTests.cs @@ -366,12 +366,14 @@ public void DialogSettings_LoadAndRoundTrip() config.DialogFolders = [new() { App = "notepad", Path = @"D:\Notes" }]; config.Hotkey = "Ctrl+Alt+F"; config.ShowDrives = true; + config.ShowAppRecent = true; var vm = NewVm(config); Assert.True(vm.DialogLastFolder); Assert.Equal("Ctrl+Alt+F", vm.Hotkey); Assert.True(vm.ShowDrives); + Assert.True(vm.ShowAppRecent); var row = Assert.Single(vm.DialogFolders); Assert.Equal("notepad", row.App); Assert.Equal(@"D:\Notes", row.Path); @@ -382,6 +384,7 @@ public void DialogSettings_LoadAndRoundTrip() Assert.True(built.DialogLastFolder); Assert.Equal("Ctrl+Alt+F", built.Hotkey); Assert.True(built.ShowDrives); + Assert.True(built.ShowAppRecent); var saved = Assert.Single(built.DialogFolders); Assert.Equal("notepad", saved.App); Assert.Equal(@"D:\Notes", saved.Path);