Compare commits

..

No commits in common. "main" and "0.2.0" have entirely different histories.
main ... 0.2.0

12 changed files with 212 additions and 1439 deletions

View file

@ -7,63 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.5.0] - 2025-10-07
### Changes
- refactor: reorganize settings tab layout and improve section headings
- feat: add create new folder option in folder navigator modal
## [0.4.1] - 2025-09-30
### Changes
- style: remove accent color from suggestion highlights
## [0.4.0] - 2025-09-30
### Changes
- refactor: improve folder search by excluding redundant child matches instead of deprioritizing
- feat: implement keyword highlighting in folder search suggestions
- refactor: remove unused match position tracking in folder search results
- improve fuzzy search
- Update README.md
- Improve code format
## [0.3.1] - 2025-04-30
### Changes
- Refactor settings tab to dynamically show/hide folder history options based on mode
- Rename FolderSortMode to FolderDisplayMode and update related UI text for clarity
- Improve code format
## [0.3.0] - 2025-04-30
### Changes
- Add visual separators and confirmation modal for folder history reset
- Add sort options
- Add demo
- Improve code format
## [0.2.1] - 2025-04-06
### Changes
- Clean up code
- Fix folder expansion
- Update README.md
- Update README.md
- Improve code format
## [0.2.0] - 2025-04-05
### Changes

View file

@ -4,61 +4,39 @@
An [Obsidian](https://obsidian.md) plugin that allows you to quickly navigate to folders in your vault using fuzzy search.
![demo](/docs/attachment/demo.gif)
## Features
- Quick folder navigation using fuzzy search
- Expand target folder on navigation
- Smart folder prioritization options:
- Default order (alphabetical)
- Recently visited folders displayed at the top
- Frequently visited folders displayed at the top
- Option to reset folder navigation history if needed
- Configurable maximum number of search results
- Works with nested folders
## Usage
## Videos and Articles
1. Press the hotkey (default: Cmd/Ctrl+Shift+O) to open the folder search modal
2. Type to search for folders - the search is fuzzy, so you don't need to type the exact name
3. Use arrow keys to navigate through the results
4. Press Enter to select a folder and navigate to it in the file explorer
### Articles
## Settings
- [The Strategy of Note Taking: Folders, Tags, Links, and Redundancy - PTKM](https://ptkm.net/blog-note-taking-strategy-folders-tags-links-redundancy)
- **Hotkey**: Customize the keyboard shortcut to open the folder navigator (requires restart)
- **Maximum results**: Set the maximum number of folders to show in search results (5-50)
## Why Folder Navigator?
## Installation
I really enjoy using Obsidian's file search feature, which allows us to quickly search for files and navigate to them. There are also third-party plugins that further enhance this experience.
1. Open Settings in Obsidian
2. Navigate to Community Plugins and disable Safe Mode
3. Click Browse and search for "Folder Navigator"
4. Install the plugin
5. Enable the plugin in the Community Plugins tab
However, I need to navigate to folders in a similar way. Unfortunately, I couldn't find a method to do this, either by using Obsidian's official features or third-party plugins. That's why I'm developing this plugin to achieve that. **It allows you to navigate to folders just as you can when navigating to files.**
## Manual Installation
## Documentation
1. Download the latest [release](https://github.com/wenlzhang/obsidian-folder-navigator/releases)
2. Extract the files into your vault's `.obsidian/plugins/obsidian-folder-navigator/` directory
3. Reload Obsidian
4. Enable the plugin in Settings > Community Plugins
📚 **[View Full Documentation](https://ptkm.net/obsidian-folder-navigator)**
## Support
Visit the documentation site to learn how to make the most of Folder Navigator in your Obsidian workflow.
## Support & Community
This plugin is developed and maintained during my free time. A lot of thought, energy, and care goes into making it reliable and user-friendly.
If you find Folder Navigator valuable in your daily workflow:
- If it helps you organize your folders more effectively
- If it saves you time and mental energy navigating your knowledge base
Please consider supporting my work. Your support would help me dedicate more time and energy to:
- Developing new features
- Maintaining code quality
- Providing support and documentation
- Making the plugin even better for everyone
### Ways to Support
You can support this project in several ways:
- ⭐ Star the project on GitHub
- 💝 <a href='https://ko-fi.com/C0C66C1TB' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
- 💌 Share your success stories and feedback
- 📢 Spread the word about the plugin
- 🐛 [Report issues](https://github.com/wenlzhang/obsidian-folder-navigator/issues) to help improve the plugin
Thank you for being part of this journey! 🙏
<a href='https://ko-fi.com/C0C66C1TB' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

View file

@ -1,169 +0,0 @@
# Search Strategy Improvements
This document describes the improvements made to the folder search functionality to address issues #4 and #8.
## Issues Addressed
### Issue #4: Better Match Prioritization
**Problem**: When searching for "abc", the plugin would show "abc" along with all its subdirectories like "abc/xxx", "abc/yyy", etc., making it difficult to find the target folder.
**Solution**: Implemented intelligent parent-child deduplication that:
- Prioritizes parent folder matches over their children
- Completely excludes child folders unless they have additional keyword matches beyond what the parent has
- Only shows children when they contain additional matched keywords not found in the parent path
### Issue #8: Out-of-Order Keyword Matching
**Problem**: Search only worked when keywords were in the correct order. For example, searching "bar foo" would not match "foo/bar/baz".
**Solution**: Implemented keyword-based matching that:
- Splits the search query into space-separated keywords
- Matches keywords independently in any order
- Requires all keywords to be present in the folder path for a match
## Implementation Details
### Custom `getSuggestions` Method
The plugin now uses a custom `getSuggestions` method that overrides the default fuzzy search with a more sophisticated scoring system:
#### 1. Keyword Extraction
```typescript
const keywords = normalizedQuery.split(/\s+/).filter((k) => k.length > 0);
```
- Splits query by whitespace
- Filters out empty strings
- Allows matching keywords in any order
#### 2. Scoring System
Each folder is scored based on multiple factors:
**Keyword Match Bonuses:**
- **+100**: Keyword matches in folder name (not just parent path)
- **+200**: Exact folder name match
- **+50**: Folder name starts with keyword
- **+10**: Keyword only matches in parent path
- **+0 to +50**: Earlier matches in path get higher scores
**Path-Based Adjustments:**
- **-5 per level**: Penalty for nested depth (prefers shallower folders)
- **+0 to +100**: Bonus for shorter paths (more specific matches)
#### 3. Parent-Child Deduplication
After initial scoring, the algorithm applies deduplication:
```typescript
// Count total occurrences of all keywords in both parent and child
let parentOccurrences = 0;
let childOccurrences = 0;
for (const keyword of keywords) {
// Count occurrences of this keyword in parent
let pos = 0;
while ((pos = parentLower.indexOf(keyword, pos)) !== -1) {
parentOccurrences++;
pos += keyword.length;
}
// Count occurrences of this keyword in child
pos = 0;
while ((pos = childLower.indexOf(keyword, pos)) !== -1) {
childOccurrences++;
pos += keyword.length;
}
}
// If child doesn't have more keyword occurrences than parent, exclude it
if (childOccurrences <= parentOccurrences) {
shouldExclude = true;
}
```
This ensures that when searching for "abc":
- "abc" is shown (1 occurrence)
- "abc/xxx" is completely hidden (still 1 occurrence of "abc")
- "abc/xxx/abc" would be shown (2 occurrences of "abc")
#### 4. Result Limiting
Results are sorted by final score and limited to the `maxResults` setting.
## Examples
### Example 1: Out-of-Order Keywords (Issue #8)
**Query:** "bar foo"
**Matches:**
- ✅ `foo/bar/baz` - Both keywords present
- ✅ `projects/foo/notes/bar` - Both keywords present
- ❌ `foo/test` - Missing "bar" keyword
- ❌ `bar/test` - Missing "foo" keyword
### Example 2: Parent-Child Prioritization (Issue #4)
**Query:** "abc"
**Before (old behavior):**
1. `abc` (score: 350)
2. `abc/xxx` (score: 340)
3. `abc/yyy` (score: 340)
4. `abc/zzz` (score: 340)
5. `projects/abc` (score: 150)
**After (new behavior):**
1. `abc` (score: 350)
2. `projects/abc` (score: 150)
3. (Other non-related folders...)
Note: `abc/xxx`, `abc/yyy`, `abc/zzz` are completely excluded from results
### Example 3: Additional Keyword in Child
**Query:** "abc test"
**Results:**
1. `abc/test` (score: 450) - Matches both keywords, shown
2. `abc` (score: 350) - Only matches "abc", shown
3. `projects/abc/test` (score: 340) - Matches both, shown
4. Other folders matching both keywords...
Note: `abc/test` is shown because it has 2 total keyword occurrences ("abc" + "test"), more than its parent `abc` which only has 1. However, `abc/notes` would be excluded because it still only has 1 occurrence of "abc", the same count as its parent.
### Example 4: Repeated Keywords
**Query:** "ai prompt"
**Folder Structure:**
- `AI/Prompt engineering` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences
- `AI/Prompt engineering/Prompt engineering` - Contains "ai" (1x) and "prompt" (2x) = 3 total occurrences
- `AI/Prompt engineering/asset` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences
**Results:**
1. `AI/Prompt engineering` - Shown (2 occurrences)
2. `AI/Prompt engineering/Prompt engineering` - Shown (3 occurrences, more than parent)
3. `AI/Prompt engineering/asset` - Hidden (2 occurrences, same as parent)
## Technical Notes
### Type Safety
- Added `FolderWithScore` interface for internal scoring
- Maintains compatibility with Obsidian's `FuzzyMatch<TFolder>` type
- Proper handling of separator items
### Debug Support
- All search operations respect the `debugMode` setting
- Logs query, keywords, and result counts when debug mode is enabled
- Useful for troubleshooting search behavior
### Performance Considerations
- Iterates through all folders once for scoring
- Uses Set for efficient parent path lookups
- Limits results to prevent UI overload
- Complexity: O(n * k) where n = folders, k = keywords
## Future Improvements
Potential enhancements for consideration:
1. **Fuzzy character matching**: Allow typos and approximate matches
2. **History-weighted scoring**: Boost scores for recently/frequently accessed folders
3. **Custom weights**: Let users adjust scoring parameters
4. **Regex support**: Enable advanced pattern matching
5. **Path segment prioritization**: Give higher weight to matches in specific segments

View file

@ -1,7 +1,7 @@
{
"id": "folder-navigator",
"name": "Folder Navigator",
"version": "0.5.0",
"version": "0.2.0",
"minAppVersion": "1.5.3",
"description": "Quickly navigate to folders in your vault using fuzzy search.",
"author": "wenlzhang",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "plugin-template",
"version": "0.5.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plugin-template",
"version": "0.5.0",
"version": "0.2.0",
"dependencies": {
"tslib": "^2.8.1"
},

View file

@ -1,6 +1,6 @@
{
"name": "folder-navigator",
"version": "0.5.0",
"version": "0.2.0",
"description": "Quickly navigate to folders in your vault using fuzzy search.",
"main": "main.js",
"scripts": {

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,11 @@
import { App } from "obsidian";
export enum FolderDisplayMode {
DEFAULT = "default",
RECENCY = "recency",
FREQUENCY = "frequency",
}
export interface PluginSettings {
maxResults: number;
expandTargetFolder: boolean;
debugMode: boolean;
folderDisplayMode: FolderDisplayMode;
recentFoldersToShow: number;
frequentFoldersToShow: number;
folderHistory: Record<
string,
{
lastAccessed: number; // timestamp
accessCount: number; // visit count
}
>;
newFolderLocation: string; // Path where new folders should be created (empty string = root)
}
export const DEFAULT_SETTINGS: PluginSettings = {
maxResults: 10,
expandTargetFolder: true, // Enable by default for better navigation
debugMode: false, // Disabled by default
folderDisplayMode: FolderDisplayMode.DEFAULT,
recentFoldersToShow: 5,
frequentFoldersToShow: 5,
folderHistory: {},
newFolderLocation: "", // Empty string means root folder
};

View file

@ -1,171 +1,18 @@
import {
App,
PluginSettingTab,
Setting,
DropdownComponent,
Modal,
Notice,
FuzzySuggestModal,
TFolder,
} from "obsidian";
import { App, PluginSettingTab, Setting } from "obsidian";
import FolderNavigatorPlugin from "./main";
import { FolderDisplayMode } from "./settings";
/**
* Modal for searching and selecting a folder for new folder creation location
*/
class FolderSelectionModal extends FuzzySuggestModal<TFolder> {
plugin: FolderNavigatorPlugin;
onSelect: (folder: TFolder) => void;
constructor(
app: App,
plugin: FolderNavigatorPlugin,
onSelect: (folder: TFolder) => void,
) {
super(app);
this.plugin = plugin;
this.onSelect = onSelect;
this.setPlaceholder("Search for a folder...");
}
getItems(): TFolder[] {
return this.app.vault.getAllFolders();
}
getItemText(folder: TFolder): string {
return folder.path || "/";
}
onChooseItem(folder: TFolder): void {
this.onSelect(folder);
}
}
export class SettingsTab extends PluginSettingTab {
plugin: FolderNavigatorPlugin;
// Keep track of settings elements for dynamic updates
recentFoldersSettingEl: HTMLElement;
frequentFoldersSettingEl: HTMLElement;
resetHistorySettingEl: HTMLElement;
constructor(app: App, plugin: FolderNavigatorPlugin) {
super(app, plugin);
this.plugin = plugin;
}
/**
* Updates visibility of settings based on the current display mode
*/
updateSettingsVisibility(displayMode: FolderDisplayMode): void {
// Create settings elements if they don't exist yet
if (
!this.recentFoldersSettingEl ||
!this.frequentFoldersSettingEl ||
!this.resetHistorySettingEl
) {
return;
}
// Handle recent folders setting
if (displayMode === FolderDisplayMode.RECENCY) {
this.recentFoldersSettingEl.style.display = "block";
this.frequentFoldersSettingEl.style.display = "none";
} else if (displayMode === FolderDisplayMode.FREQUENCY) {
this.recentFoldersSettingEl.style.display = "none";
this.frequentFoldersSettingEl.style.display = "block";
} else {
// Default mode - hide both
this.recentFoldersSettingEl.style.display = "none";
this.frequentFoldersSettingEl.style.display = "none";
}
// Only show reset history button for non-default modes
if (displayMode === FolderDisplayMode.DEFAULT) {
this.resetHistorySettingEl.style.display = "none";
} else {
this.resetHistorySettingEl.style.display = "block";
}
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Folder display
new Setting(containerEl).setName("Folder display").setHeading();
// Display priority
new Setting(containerEl)
.setName("Display priority")
.setDesc(
"Control which folders appear at the top of the suggestion list before searching",
)
.addDropdown((dropdown: DropdownComponent) => {
dropdown
.addOption(
FolderDisplayMode.DEFAULT,
"Default order (alphabetical)",
)
.addOption(
FolderDisplayMode.RECENCY,
"Recently visited first",
)
.addOption(
FolderDisplayMode.FREQUENCY,
"Frequently visited first",
)
.setValue(this.plugin.settings.folderDisplayMode)
.onChange(async (value: string) => {
this.plugin.settings.folderDisplayMode =
value as FolderDisplayMode;
await this.plugin.saveSettings();
// Update the visibility of settings when selection changes
this.updateSettingsVisibility(
value as FolderDisplayMode,
);
});
});
// Recent folders
this.recentFoldersSettingEl = containerEl.createDiv();
new Setting(this.recentFoldersSettingEl)
.setName("Recent folders to show")
.setDesc(
"Number of recently visited folders to display at the top when using 'Recently visited first' mode",
)
.addSlider((slider) =>
slider
.setLimits(1, 20, 1)
.setValue(this.plugin.settings.recentFoldersToShow)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.recentFoldersToShow = value;
await this.plugin.saveSettings();
}),
);
// Frequent folders
this.frequentFoldersSettingEl = containerEl.createDiv();
new Setting(this.frequentFoldersSettingEl)
.setName("Frequent folders to show")
.setDesc(
"Number of frequently visited folders to display at the top when using 'Frequently visited first' mode",
)
.addSlider((slider) =>
slider
.setLimits(1, 20, 1)
.setValue(this.plugin.settings.frequentFoldersToShow)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.frequentFoldersToShow = value;
await this.plugin.saveSettings();
}),
);
// Maximum results
new Setting(containerEl)
.setName("Maximum results")
.setDesc("Maximum number of folders to show in search results")
@ -180,99 +27,6 @@ export class SettingsTab extends PluginSettingTab {
}),
);
// Reset history
this.resetHistorySettingEl = containerEl.createDiv();
new Setting(this.resetHistorySettingEl)
.setName("Reset folder history")
.setDesc(
"Clear the tracked history of visited folders. Warning: This will remove all recency and frequency data.",
)
.addButton((button) =>
button
.setButtonText("Reset")
.setWarning()
.onClick(async () => {
// Create a confirmation modal
const confirmModal = new Modal(this.app);
confirmModal.titleEl.setText("Reset folder history");
confirmModal.contentEl.createEl("p", {
text: "Are you sure? This will permanently delete all folder navigation history including recently visited and frequently used folder data.",
cls: "mod-warning",
});
const buttonContainer =
confirmModal.contentEl.createDiv({
cls: "modal-button-container",
});
// Cancel button
buttonContainer
.createEl("button", {
text: "Cancel",
})
.addEventListener("click", () => {
confirmModal.close();
});
// Confirm button
const confirmButton = buttonContainer.createEl(
"button",
{
text: "Reset folder history",
cls: "mod-warning",
},
);
confirmButton.addEventListener("click", async () => {
this.plugin.settings.folderHistory = {};
await this.plugin.saveSettings();
confirmModal.close();
new Notice("Folder history has been reset");
});
confirmModal.open();
}),
);
// New folder location
new Setting(containerEl).setName("Folder creation").setHeading();
new Setting(containerEl)
.setName("Default location for new folders")
.setDesc(
"Choose where new folders will be created when using the 'Create folder' option. Default is the vault root.",
)
.addButton((button) => {
const displayPath =
this.plugin.settings.newFolderLocation || "/ (Root)";
button.setButtonText(displayPath).onClick(() => {
const modal = new FolderSelectionModal(
this.app,
this.plugin,
async (folder: TFolder) => {
this.plugin.settings.newFolderLocation =
folder.path;
await this.plugin.saveSettings();
button.setButtonText(folder.path || "/ (Root)");
},
);
modal.open();
});
})
.addExtraButton((button) => {
button
.setIcon("reset")
.setTooltip("Reset to root folder")
.onClick(async () => {
this.plugin.settings.newFolderLocation = "";
await this.plugin.saveSettings();
// Update button text by recreating the setting
this.display();
});
});
// Folder navigation
new Setting(containerEl).setName("Folder navigation").setHeading();
new Setting(containerEl)
.setName("Expand target folder on navigation")
.setDesc(
@ -286,25 +40,5 @@ export class SettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
}),
);
// Initialize settings visibility based on current display mode
this.updateSettingsVisibility(this.plugin.settings.folderDisplayMode);
// Add advanced section
new Setting(containerEl).setName("Advanced").setHeading();
new Setting(containerEl)
.setName("Debug mode")
.setDesc(
"Enable detailed logging to console for troubleshooting issues. This may affect performance and should be disabled during normal use.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.debugMode)
.onChange(async (value) => {
this.plugin.settings.debugMode = value;
await this.plugin.saveSettings();
}),
);
}
}

View file

@ -3,34 +3,3 @@
border-radius: 4px;
transition: background-color 0.3s ease;
}
.folder-separator {
color: var(--text-muted);
font-size: 0.85em;
text-align: center;
padding: 5px 0;
font-style: italic;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 5px;
pointer-events: none;
}
.suggestion-highlight {
font-weight: bold;
}
.folder-create-option {
display: flex;
align-items: center;
padding: 4px 0;
color: var(--text-accent);
}
.folder-create-icon {
margin-right: 6px;
font-size: 1.1em;
}
.folder-create-text {
font-style: italic;
}

View file

@ -1,11 +1,5 @@
{
"0.0.1": "0.0.1",
"0.1.0": "1.0.0",
"0.2.0": "1.5.3",
"0.2.1": "1.5.3",
"0.3.0": "1.5.3",
"0.3.1": "1.5.3",
"0.4.0": "1.5.3",
"0.4.1": "1.5.3",
"0.5.0": "1.5.3"
"0.2.0": "1.5.3"
}