Compare commits

...

16 commits
0.3.0 ... main

11 changed files with 1003 additions and 181 deletions

View file

@ -9,6 +9,42 @@ 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

View file

@ -9,15 +9,19 @@ An [Obsidian](https://obsidian.md) plugin that allows you to quickly navigate to
## Features
- Quick folder navigation using fuzzy search
- Configurable maximum number of search results
- Expand target folder on navigation
- Works with nested folders
- Smart folder sorting options:
- Default sorting (alphabetical)
- Recently visited folders displayed at the top
- Frequently visited folders displayed at the top
- Customizable number of recent/frequent folders to show
- Visual separators to distinguish recent/frequent folders
- 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
## Videos and Articles
### Articles
- [The Strategy of Note Taking: Folders, Tags, Links, and Redundancy - PTKM](https://ptkm.net/blog-note-taking-strategy-folders-tags-links-redundancy)
## Why Folder Navigator?

View file

@ -0,0 +1,169 @@
# 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.3.0",
"version": "0.5.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.3.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plugin-template",
"version": "0.3.0",
"version": "0.5.0",
"dependencies": {
"tslib": "^2.8.1"
},

View file

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

View file

@ -1,6 +1,14 @@
import { App, FuzzySuggestModal, TFolder, WorkspaceLeaf, View, FuzzyMatch } from "obsidian";
import {
App,
FuzzySuggestModal,
TFolder,
WorkspaceLeaf,
View,
FuzzyMatch,
Notice,
} from "obsidian";
import type FolderNavigatorPlugin from "./main";
import { FolderSortMode } from "./settings";
import { FolderDisplayMode } from "./settings";
// Helper function for conditional logging based on plugin settings
function log(
@ -40,6 +48,17 @@ interface ExtendedApp extends App {
internalPlugins: InternalPlugins;
}
interface FolderWithScore {
folder: TFolder;
score: number;
match: FuzzyMatch<TFolder>;
}
interface CreateFolderOption extends TFolder {
_isCreateOption: boolean;
_folderName: string;
}
export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
folders: TFolder[];
plugin: FolderNavigatorPlugin;
@ -61,28 +80,30 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
}
/**
* Sort folders based on the selected sort mode in settings
* Sort folders based on the selected display mode in settings
*/
private getSortedFolders(): TFolder[] {
const allFolders = this.getAllFolders();
const sortMode = this.plugin.settings.folderSortMode;
log(`Sorting folders using mode: ${sortMode}`, this.plugin);
const displayMode = this.plugin.settings.folderDisplayMode;
log(`Sorting folders using display mode: ${displayMode}`, this.plugin);
// Default sorting (alphabetical by path)
if (sortMode === FolderSortMode.DEFAULT) {
if (displayMode === FolderDisplayMode.DEFAULT) {
return allFolders;
}
// Get folder history from settings
const folderHistory = this.plugin.settings.folderHistory;
if (sortMode === FolderSortMode.RECENCY) {
if (displayMode === FolderDisplayMode.RECENCY) {
// Create a copy of all folders to sort
const recentLimit = this.plugin.settings.recentFoldersToShow;
// Find folders with history and sort by last accessed timestamp (descending)
const foldersWithHistory = allFolders.filter(f => folderHistory[f.path]);
const foldersWithHistory = allFolders.filter(
(f) => folderHistory[f.path],
);
const recentFolders = foldersWithHistory
.sort((a, b) => {
const timeA = folderHistory[a.path]?.lastAccessed || 0;
@ -90,39 +111,52 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
return timeB - timeA; // Descending order (newest first)
})
.slice(0, recentLimit);
// Get the remaining folders (those without history or beyond the limit)
const recentFolderPaths = new Set(recentFolders.map(f => f.path));
const remainingFolders = allFolders.filter(f => !recentFolderPaths.has(f.path));
log(`Found ${recentFolders.length} recent folders to display`, this.plugin);
const recentFolderPaths = new Set(recentFolders.map((f) => f.path));
const remainingFolders = allFolders.filter(
(f) => !recentFolderPaths.has(f.path),
);
log(
`Found ${recentFolders.length} recent folders to display`,
this.plugin,
);
// Only add separator if we have recent folders to show
if (recentFolders.length > 0) {
// Create a separator "folder" (not a real folder)
const separator = {} as TFolder;
(separator as any)._isSeparator = true;
(separator as any)._separatorText = "— Recently visited folders —";
(separator as any)._separatorText =
"— Recently visited folders —";
// Create another separator for the remaining folders
const remainingSeparator = {} as TFolder;
(remainingSeparator as any)._isSeparator = true;
(remainingSeparator as any)._separatorText = "— All folders —";
// Combine recent folders with remaining folders with separators
return [separator, ...recentFolders, remainingSeparator, ...remainingFolders];
return [
separator,
...recentFolders,
remainingSeparator,
...remainingFolders,
];
}
// If no recent folders, just return all folders
return allFolders;
}
if (sortMode === FolderSortMode.FREQUENCY) {
if (displayMode === FolderDisplayMode.FREQUENCY) {
// Create a copy of all folders to sort
const frequentLimit = this.plugin.settings.frequentFoldersToShow;
// Find folders with history and sort by access count (descending)
const foldersWithHistory = allFolders.filter(f => folderHistory[f.path]);
const foldersWithHistory = allFolders.filter(
(f) => folderHistory[f.path],
);
const frequentFolders = foldersWithHistory
.sort((a, b) => {
const countA = folderHistory[a.path]?.accessCount || 0;
@ -130,33 +164,46 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
return countB - countA; // Descending order (most frequent first)
})
.slice(0, frequentLimit);
// Get the remaining folders (those without history or beyond the limit)
const frequentFolderPaths = new Set(frequentFolders.map(f => f.path));
const remainingFolders = allFolders.filter(f => !frequentFolderPaths.has(f.path));
log(`Found ${frequentFolders.length} frequent folders to display`, this.plugin);
const frequentFolderPaths = new Set(
frequentFolders.map((f) => f.path),
);
const remainingFolders = allFolders.filter(
(f) => !frequentFolderPaths.has(f.path),
);
log(
`Found ${frequentFolders.length} frequent folders to display`,
this.plugin,
);
// Only add separator if we have frequent folders to show
if (frequentFolders.length > 0) {
// Create a separator "folder" (not a real folder)
const separator = {} as TFolder;
(separator as any)._isSeparator = true;
(separator as any)._separatorText = "— Frequently visited folders —";
(separator as any)._separatorText =
"— Frequently visited folders —";
// Create another separator for the remaining folders
const remainingSeparator = {} as TFolder;
(remainingSeparator as any)._isSeparator = true;
(remainingSeparator as any)._separatorText = "— All folders —";
// Combine frequent folders with remaining folders with separators
return [separator, ...frequentFolders, remainingSeparator, ...remainingFolders];
return [
separator,
...frequentFolders,
remainingSeparator,
...remainingFolders,
];
}
// If no frequent folders, just return all folders
return allFolders;
}
// Fallback to default sorting
return allFolders;
}
@ -167,22 +214,286 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
private updateFolderHistory(folder: TFolder): void {
// Get current history or create new entry
const folderPath = folder.path;
const history = this.plugin.settings.folderHistory[folderPath] || {
const history = this.plugin.settings.folderHistory[folderPath] || {
lastAccessed: 0,
accessCount: 0
accessCount: 0,
};
// Update history data
history.lastAccessed = Date.now();
history.accessCount += 1;
// Save back to settings
this.plugin.settings.folderHistory[folderPath] = history;
// Save settings
this.plugin.saveSettings();
log(`Updated folder history for "${folderPath}": ${JSON.stringify(history)}`, this.plugin);
log(
`Updated folder history for "${folderPath}": ${JSON.stringify(history)}`,
this.plugin,
);
}
/**
* Custom search implementation with improved fuzzy matching
* Supports out-of-order keyword matching and better scoring
*/
getSuggestions(query: string): FuzzyMatch<TFolder>[] {
// If no query, return all items (or recent/frequent items based on settings)
if (!query || query.trim().length === 0) {
return this.sortedFolders.map((folder) => ({
item: folder,
match: { score: 0, matches: [] },
}));
}
const normalizedQuery = query.toLowerCase().trim();
// Split query into keywords (space-separated)
const keywords = normalizedQuery
.split(/\s+/)
.filter((k) => k.length > 0);
log(
`Search query: "${query}", keywords: [${keywords.join(", ")}]`,
this.plugin,
);
// Score each folder
const foldersWithScore: FolderWithScore[] = [];
for (const folder of this.sortedFolders) {
// Skip separators
if ((folder as any)._isSeparator) {
foldersWithScore.push({
folder,
score: -1,
match: { item: folder, match: { score: 0, matches: [] } },
});
continue;
}
const folderPath = folder.path.toLowerCase();
const folderName = folder.name.toLowerCase();
const originalPath = folder.path; // Keep original case for match positions
// Check if all keywords match (in any order)
let allKeywordsMatch = true;
let totalScore = 0;
const matchPositions: Array<[number, number]> = [];
for (const keyword of keywords) {
const pathIndex = folderPath.indexOf(keyword);
const nameIndex = folderName.indexOf(keyword);
if (pathIndex === -1) {
// Keyword doesn't match this folder at all
allKeywordsMatch = false;
break;
}
// Store match positions for highlighting (using original path positions)
// Find ALL occurrences of this keyword in the original case-sensitive path
const originalPathLower = originalPath.toLowerCase();
let pos = 0;
while ((pos = originalPathLower.indexOf(keyword, pos)) !== -1) {
// Record character positions for this occurrence
for (let i = 0; i < keyword.length; i++) {
matchPositions.push([pos + i, pos + i]);
}
pos += keyword.length; // Move past this occurrence to find the next one
}
// Calculate score for this keyword
let keywordScore = 0;
// Bonus for matching in folder name (not just path)
if (nameIndex !== -1) {
keywordScore += 100;
// Extra bonus for exact name match
if (folderName === keyword) {
keywordScore += 200;
}
// Bonus for name starting with keyword
else if (nameIndex === 0) {
keywordScore += 50;
}
} else {
// Match is in parent path, lower score
keywordScore += 10;
}
// Bonus for match position (earlier is better)
keywordScore += Math.max(0, 50 - pathIndex);
totalScore += keywordScore;
}
if (allKeywordsMatch) {
// Additional scoring adjustments
// Penalize deeper nested folders
const depth = folder.path.split("/").length;
totalScore -= depth * 5;
// Bonus for shorter paths (more specific)
totalScore += Math.max(0, 100 - folder.path.length);
foldersWithScore.push({
folder,
score: totalScore,
match: {
item: folder,
match: {
score: totalScore,
matches: matchPositions,
},
},
});
}
}
// Sort by score (descending)
foldersWithScore.sort((a, b) => {
// Keep separators at their original positions
if (
(a.folder as any)._isSeparator ||
(b.folder as any)._isSeparator
) {
return 0;
}
return b.score - a.score;
});
// Apply parent folder deduplication for Issue #4
// When a parent folder matches, completely exclude its children unless they have additional keyword matches
const results: FuzzyMatch<TFolder>[] = [];
const parentPaths = new Set<string>();
for (const item of foldersWithScore) {
// Always include separators
if ((item.folder as any)._isSeparator) {
results.push(item.match);
continue;
}
const folderPath = item.folder.path;
// Check if this folder's parent is already in results
let shouldExclude = false;
let isChildOfMatch = false;
for (const parentPath of parentPaths) {
// Check if current folder is a child of an already matched parent
if (folderPath.startsWith(parentPath + "/")) {
isChildOfMatch = true;
// Check if this child has additional keyword occurrences beyond the parent
// Count total occurrences of all keywords in both parent and child
const parentLower = parentPath.toLowerCase();
const childLower = folderPath.toLowerCase();
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;
}
break;
}
}
// Only include if not excluded
if (!shouldExclude) {
results.push(item.match);
// Add this folder to parent paths for future checks
if (!isChildOfMatch) {
parentPaths.add(folderPath);
}
}
}
// Limit results
const limitedResults = results.slice(0, this.limit);
log(
`Found ${limitedResults.length} matching folders (filtered from ${foldersWithScore.length} total matches)`,
this.plugin,
);
// If query is provided, add "Create new folder" option at the end
if (normalizedQuery.length > 0) {
// Filter out separators to count real folder matches
const realFolderMatches = limitedResults.filter(
(result) => !(result.item as any)._isSeparator,
);
// Only show create option if the exact folder doesn't already exist
const exactMatch = realFolderMatches.some(
(result) =>
result.item.path.toLowerCase() === normalizedQuery ||
result.item.name.toLowerCase() === normalizedQuery,
);
if (!exactMatch) {
const createOption = {} as CreateFolderOption;
createOption._isCreateOption = true;
createOption._folderName = query.trim();
// Add separator before create option if there are results
if (limitedResults.length > 0) {
const separator = {} as TFolder;
(separator as any)._isSeparator = true;
(separator as any)._separatorText = "—————";
return [
...limitedResults,
{
item: separator,
match: { score: 0, matches: [] },
},
{
item: createOption as TFolder,
match: { score: 0, matches: [] },
},
];
} else {
// No results, just show create option
return [
{
item: createOption as TFolder,
match: { score: 0, matches: [] },
},
];
}
}
}
return limitedResults;
}
getItems(): TFolder[] {
@ -194,6 +505,10 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
if ((folder as any)._isSeparator) {
return (folder as any)._separatorText;
}
// For create folder option
if ((folder as CreateFolderOption)._isCreateOption) {
return `Create new folder: ${(folder as CreateFolderOption)._folderName}`;
}
return folder.path;
}
@ -259,25 +574,54 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
}
/**
* Event handler for when a folder is chosen from the suggestion list
* Handle creating a new folder
*/
onChooseItem(folder: TFolder, evt: MouseEvent | KeyboardEvent): void {
// Check if this is a separator and ignore if so
if ((folder as any)._isSeparator) {
return;
}
log(
`Folder selected: "${folder.path}" (name: "${folder.name}")`,
this.plugin,
);
private async handleCreateFolder(folderName: string): Promise<void> {
try {
log(`Creating new folder: "${folderName}"`, this.plugin);
// Get the base path from settings
const basePath = this.plugin.settings.newFolderLocation;
const fullPath = basePath
? `${basePath}/${folderName}`
: folderName;
log(`Full path for new folder: "${fullPath}"`, this.plugin);
// Check if folder already exists
const existingFolder =
this.app.vault.getAbstractFileByPath(fullPath);
if (existingFolder) {
new Notice(`Folder "${fullPath}" already exists`);
return;
}
// Create the folder
const newFolder = await this.app.vault.createFolder(fullPath);
log(`Folder created successfully: "${fullPath}"`, this.plugin);
new Notice(`Folder "${fullPath}" created successfully`);
// Close the modal
this.close();
// Navigate to the newly created folder
setTimeout(() => {
this.navigateToFolder(newFolder);
}, 100);
} catch (error) {
logError(`Error creating folder: ${error}`);
new Notice(`Failed to create folder: ${error.message || error}`);
}
}
/**
* Navigate to a folder (same logic as in onChooseItem)
*/
private navigateToFolder(folder: TFolder): void {
try {
// Update folder history
this.updateFolderHistory(folder);
// Close the modal first
this.close();
// Make sure the file explorer is visible
const fileExplorerLeaves =
@ -352,6 +696,35 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
}
}
/**
* Event handler for when a folder is chosen from the suggestion list
*/
onChooseItem(folder: TFolder, evt: MouseEvent | KeyboardEvent): void {
// Check if this is a separator and ignore if so
if ((folder as any)._isSeparator) {
return;
}
// Check if this is a create folder option
if ((folder as CreateFolderOption)._isCreateOption) {
this.handleCreateFolder((folder as CreateFolderOption)._folderName);
return;
}
log(
`Folder selected: "${folder.path}" (name: "${folder.name}")`,
this.plugin,
);
// Close the modal first
this.close();
// Navigate to the selected folder
setTimeout(() => {
this.navigateToFolder(folder);
}, 100);
}
/**
* Expand a folder by finding and clicking its title directly in the DOM
*/
@ -484,13 +857,70 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
if ((item.item as any)._isSeparator) {
el.empty();
const separatorEl = el.createDiv({
cls: "folder-separator"
cls: "folder-separator",
});
separatorEl.setText((item.item as any)._separatorText);
return;
}
// Regular folder rendering - use the default rendering
super.renderSuggestion(item, el);
// Check if this is a create folder option
if ((item.item as CreateFolderOption)._isCreateOption) {
el.empty();
const createEl = el.createDiv({
cls: "folder-create-option",
});
const iconEl = createEl.createSpan({
cls: "folder-create-icon",
});
iconEl.setText(" ");
const textEl = createEl.createSpan({
cls: "folder-create-text",
});
textEl.setText(
`Create new folder: "${(item.item as CreateFolderOption)._folderName}"`,
);
return;
}
// Custom rendering with keyword highlighting
el.empty();
const folder = item.item;
const folderPath = folder.path;
// Get match positions from the fuzzy match result
const matches = item.match?.matches || [];
if (matches.length === 0) {
// No matches, just display the text normally
el.setText(folderPath);
return;
}
// Sort matches by position
const sortedMatches = matches.slice().sort((a, b) => a[0] - b[0]);
// Build the highlighted text
let lastIndex = 0;
const textEl = el.createDiv({ cls: "suggestion-content" });
for (const match of sortedMatches) {
const [start, end] = match;
// Add text before the match
if (start > lastIndex) {
textEl.appendText(folderPath.substring(lastIndex, start));
}
// Add the matched text with highlighting
const matchEl = textEl.createSpan({ cls: "suggestion-highlight" });
matchEl.setText(folderPath.substring(start, end + 1));
lastIndex = end + 1;
}
// Add remaining text after the last match
if (lastIndex < folderPath.length) {
textEl.appendText(folderPath.substring(lastIndex));
}
}
}

View file

@ -1,30 +1,35 @@
import { App } from "obsidian";
export enum FolderSortMode {
export enum FolderDisplayMode {
DEFAULT = "default",
RECENCY = "recency",
FREQUENCY = "frequency"
FREQUENCY = "frequency",
}
export interface PluginSettings {
maxResults: number;
expandTargetFolder: boolean;
debugMode: boolean;
folderSortMode: FolderSortMode;
folderDisplayMode: FolderDisplayMode;
recentFoldersToShow: number;
frequentFoldersToShow: number;
folderHistory: Record<string, {
lastAccessed: number; // timestamp
accessCount: number; // visit count
}>;
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
folderSortMode: FolderSortMode.DEFAULT,
folderDisplayMode: FolderDisplayMode.DEFAULT,
recentFoldersToShow: 5,
frequentFoldersToShow: 5,
folderHistory: {},
newFolderLocation: "", // Empty string means root folder
};

View file

@ -1,20 +1,171 @@
import { App, PluginSettingTab, Setting, DropdownComponent, Modal, Notice } from "obsidian";
import {
App,
PluginSettingTab,
Setting,
DropdownComponent,
Modal,
Notice,
FuzzySuggestModal,
TFolder,
} from "obsidian";
import FolderNavigatorPlugin from "./main";
import { FolderSortMode } from "./settings";
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();
// General settings section
// 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")
@ -29,6 +180,99 @@ 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(
@ -43,98 +287,8 @@ export class SettingsTab extends PluginSettingTab {
}),
);
// Folder sorting section
new Setting(containerEl).setName("Folder sorting").setHeading();
new Setting(containerEl)
.setName("Sort mode")
.setDesc("How folders are sorted in the suggestion list")
.addDropdown((dropdown: DropdownComponent) => {
dropdown
.addOption(FolderSortMode.DEFAULT, "Default (alphabetical)")
.addOption(FolderSortMode.RECENCY, "Recently visited")
.addOption(FolderSortMode.FREQUENCY, "Frequently visited")
.setValue(this.plugin.settings.folderSortMode)
.onChange(async (value: string) => {
this.plugin.settings.folderSortMode = value as FolderSortMode;
await this.plugin.saveSettings();
});
});
// Only show these settings if the sort mode is not DEFAULT
if (this.plugin.settings.folderSortMode !== FolderSortMode.DEFAULT) {
new Setting(containerEl)
.setName("Recent folders to show")
.setDesc("Number of recently visited folders to display at the top when using the recency sort 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();
}),
);
new Setting(containerEl)
.setName("Frequent folders to show")
.setDesc("Number of frequently visited folders to display at the top when using the frequency sort 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();
}),
);
}
// Add reset history button
new Setting(containerEl)
.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();
}),
);
// Initialize settings visibility based on current display mode
this.updateSettingsVisibility(this.plugin.settings.folderDisplayMode);
// Add advanced section
new Setting(containerEl).setName("Advanced").setHeading();

View file

@ -14,3 +14,23 @@
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

@ -3,5 +3,9 @@
"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.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"
}