Compare commits

...

21 commits
0.2.1 ... main

Author SHA1 Message Date
wz
4175b9dc30 0.5.0 2025-10-07 21:26:57 +02:00
wz
28db3e9d50 refactor: reorganize settings tab layout and improve section headings 2025-10-07 21:21:37 +02:00
wz
d6f18b52c2 feat: add create new folder option in folder navigator modal 2025-10-07 21:15:30 +02:00
wz
95e5d2937f 0.4.1 2025-09-30 23:37:07 +02:00
wz
5780b3e8bc style: remove accent color from suggestion highlights 2025-09-30 23:36:47 +02:00
wz
b1af049004 0.4.0 2025-09-30 22:44:32 +02:00
wz
658adcbfe4 refactor: improve folder search by excluding redundant child matches instead of deprioritizing 2025-09-30 22:42:43 +02:00
wz
89df8eb024 feat: implement keyword highlighting in folder search suggestions 2025-09-30 22:26:39 +02:00
wz
d6de79d372 refactor: remove unused match position tracking in folder search results 2025-09-30 16:50:46 +02:00
wz
97974cc801 improve fuzzy search 2025-09-30 16:41:18 +02:00
wz
3b440f3222 Update README.md 2025-05-03 09:43:46 +02:00
wz
bf52a4b2a5 Improve code format 2025-04-30 20:40:30 +02:00
wz
78f03608ea 0.3.1 2025-04-30 20:40:05 +02:00
wz
1dd71601ce Refactor settings tab to dynamically show/hide folder history options based on mode 2025-04-30 20:39:49 +02:00
wz
71294a3b94 Rename FolderSortMode to FolderDisplayMode and update related UI text for clarity 2025-04-30 19:37:08 +02:00
wz
5929cd9cf2 Improve code format 2025-04-30 19:21:30 +02:00
wz
72506484ee 0.3.0 2025-04-30 19:21:11 +02:00
wz
99e1d036aa Add visual separators and confirmation modal for folder history reset 2025-04-30 19:20:45 +02:00
wz
eb222d3e52 Add sort options
Sort based on recency and frequency.
2025-04-30 19:00:44 +02:00
wz
5cfe391aec Add demo 2025-04-06 10:34:25 +02:00
wz
79dc260bb7 Improve code format 2025-04-06 10:25:53 +02:00
12 changed files with 1260 additions and 78 deletions

View file

@ -8,6 +8,52 @@ 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

View file

@ -4,12 +4,24 @@
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
- Configurable maximum number of search results
- Expand target folder on navigation
- Works with nested 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?

BIN
docs/attachment/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

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.2.1",
"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.2.1",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plugin-template",
"version": "0.2.1",
"version": "0.5.0",
"dependencies": {
"tslib": "^2.8.1"
},

View file

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

View file

@ -1,8 +1,21 @@
import { App, FuzzySuggestModal, TFolder, WorkspaceLeaf, View } from "obsidian";
import {
App,
FuzzySuggestModal,
TFolder,
WorkspaceLeaf,
View,
FuzzyMatch,
Notice,
} from "obsidian";
import type FolderNavigatorPlugin from "./main";
import { FolderDisplayMode } from "./settings";
// Helper function for conditional logging based on plugin settings
function log(message: string, plugin: FolderNavigatorPlugin, ...args: any[]): void {
function log(
message: string,
plugin: FolderNavigatorPlugin,
...args: any[]
): void {
if (plugin.settings.debugMode) {
console.log(message, ...args);
}
@ -15,7 +28,7 @@ function logError(message: string, ...args: any[]): void {
interface FileExplorerView extends View {
fileItems: Record<string, any>;
expandFolder?: (folder: TFolder) => void; // Optional: may not exist in all versions
expandFolder?: (folder: TFolder) => void; // Optional: may not exist in all versions
}
interface FileExplorerInstance {
@ -35,16 +48,29 @@ 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;
sortedFolders: TFolder[] = [];
constructor(app: App, plugin: FolderNavigatorPlugin) {
super(app);
this.plugin = plugin;
this.setPlaceholder("Type folder name...");
this.folders = this.getAllFolders();
this.sortedFolders = this.getSortedFolders();
// Use the max results from settings
this.limit = this.plugin.settings.maxResults;
}
@ -53,11 +79,436 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
return this.app.vault.getAllFolders();
}
/**
* Sort folders based on the selected display mode in settings
*/
private getSortedFolders(): TFolder[] {
const allFolders = this.getAllFolders();
const displayMode = this.plugin.settings.folderDisplayMode;
log(`Sorting folders using display mode: ${displayMode}`, this.plugin);
// Default sorting (alphabetical by path)
if (displayMode === FolderDisplayMode.DEFAULT) {
return allFolders;
}
// Get folder history from settings
const folderHistory = this.plugin.settings.folderHistory;
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 recentFolders = foldersWithHistory
.sort((a, b) => {
const timeA = folderHistory[a.path]?.lastAccessed || 0;
const timeB = folderHistory[b.path]?.lastAccessed || 0;
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,
);
// 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 —";
// 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,
];
}
// If no recent folders, just return all folders
return allFolders;
}
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 frequentFolders = foldersWithHistory
.sort((a, b) => {
const countA = folderHistory[a.path]?.accessCount || 0;
const countB = folderHistory[b.path]?.accessCount || 0;
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,
);
// 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 —";
// 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,
];
}
// If no frequent folders, just return all folders
return allFolders;
}
// Fallback to default sorting
return allFolders;
}
/**
* Update folder access history when a folder is selected
*/
private updateFolderHistory(folder: TFolder): void {
// Get current history or create new entry
const folderPath = folder.path;
const history = this.plugin.settings.folderHistory[folderPath] || {
lastAccessed: 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,
);
}
/**
* 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[] {
return this.folders;
return this.sortedFolders;
}
getItemText(folder: TFolder): string {
// For separator folders (which will have a special property), return the separator text
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;
}
@ -67,12 +518,12 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
private getParentFolderChain(folder: TFolder): TFolder[] {
const parentChain: TFolder[] = [];
let currentFolder: TFolder | null = folder.parent;
while (currentFolder) {
parentChain.unshift(currentFolder); // Add at beginning to get root->leaf order
currentFolder = currentFolder.parent;
}
return parentChain;
}
@ -82,28 +533,35 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
private getFileExplorer(): FileExplorerInstance | null {
try {
const app = this.app as ExtendedApp;
if (!app.internalPlugins?.plugins["file-explorer"]?.instance) {
logError("Could not find file explorer plugin instance");
return null;
}
const fileExplorer = app.internalPlugins.plugins["file-explorer"].instance;
const fileExplorer =
app.internalPlugins.plugins["file-explorer"].instance;
if (typeof fileExplorer.revealInFolder !== "function") {
logError("revealInFolder method not found on file explorer instance");
logError(
"revealInFolder method not found on file explorer instance",
);
return null;
}
// Log available methods for debugging
if (this.plugin.settings.debugMode) {
log("File explorer instance methods:", this.plugin);
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(fileExplorer));
const methods = Object.getOwnPropertyNames(
Object.getPrototypeOf(fileExplorer),
);
log(methods.join(", "), this.plugin);
if (fileExplorer.view) {
log("File explorer view methods:", this.plugin);
const viewMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(fileExplorer.view));
const viewMethods = Object.getOwnPropertyNames(
Object.getPrototypeOf(fileExplorer.view),
);
log(viewMethods.join(", "), this.plugin);
}
}
@ -116,45 +574,92 @@ 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): void {
log(`Folder selected: "${folder.path}" (name: "${folder.name}")`, this.plugin);
private async handleCreateFolder(folderName: string): Promise<void> {
try {
// Close the modal first
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);
// Make sure the file explorer is visible
const fileExplorerLeaves = this.app.workspace.getLeavesOfType("file-explorer");
const fileExplorerLeaves =
this.app.workspace.getLeavesOfType("file-explorer");
if (fileExplorerLeaves.length === 0) {
logError("No file explorer leaf found");
return;
}
const fileExplorerLeaf = fileExplorerLeaves[0];
this.app.workspace.revealLeaf(fileExplorerLeaf);
log("File explorer leaf revealed", this.plugin);
// Get the file explorer instance
const fileExplorer = this.getFileExplorer();
if (!fileExplorer) {
logError("Failed to get file explorer instance");
return;
}
// Step 1: Reveal the folder in the file explorer
log(`Revealing folder in explorer: "${folder.path}"`, this.plugin);
fileExplorer.revealInFolder(folder);
// Step 2: Wait a bit for the UI to update, then expand folders using DOM
setTimeout(() => {
if (this.plugin.settings.expandTargetFolder) {
// Get all parent folders in order
log(`Expanding parent folders for: "${folder.path}"`, this.plugin);
log(
`Expanding parent folders for: "${folder.path}"`,
this.plugin,
);
const parentFolders = this.getParentFolderChain(folder);
log(`Parent chain: ${parentFolders.map(f => f.name).join(" -> ")}`, this.plugin);
log(
`Parent chain: ${parentFolders.map((f) => f.name).join(" -> ")}`,
this.plugin,
);
// First, expand all parent folders in order from root to leaf
parentFolders.forEach((parentFolder, index) => {
// Use increasing delays to ensure proper sequence
@ -162,17 +667,23 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
this.expandFolderByDOMClick(parentFolder.name);
}, index * 50);
});
// Then expand the target folder itself (after parents are expanded)
setTimeout(() => {
log(`Expanding target folder: "${folder.name}"`, this.plugin);
this.expandFolderByDOMClick(folder.name);
// Highlight the folder after expansion
setTimeout(() => {
this.highlightFolderInDOM(folder.name);
}, 200);
}, parentFolders.length * 50 + 100);
setTimeout(
() => {
log(
`Expanding target folder: "${folder.name}"`,
this.plugin,
);
this.expandFolderByDOMClick(folder.name);
// Highlight the folder after expansion
setTimeout(() => {
this.highlightFolderInDOM(folder.name);
}, 200);
},
parentFolders.length * 50 + 100,
);
} else {
// Just highlight the folder if we're not expanding
setTimeout(() => {
@ -184,95 +695,232 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
logError(`Error in folder navigation: ${error}`);
}
}
/**
* 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
*/
private expandFolderByDOMClick(folderName: string): void {
try {
log(`Looking for folder to expand: "${folderName}"`, this.plugin);
// Find all folder title content elements
const allTitleContents = document.querySelectorAll(".nav-folder-title-content");
const allTitleContents = document.querySelectorAll(
".nav-folder-title-content",
);
for (const titleContent of Array.from(allTitleContents)) {
if (titleContent.textContent === folderName) {
log(`Found folder title content: "${folderName}"`, this.plugin);
log(
`Found folder title content: "${folderName}"`,
this.plugin,
);
// Get the title element
const titleElement = titleContent.closest(".nav-folder-title");
const titleElement =
titleContent.closest(".nav-folder-title");
if (!(titleElement instanceof HTMLElement)) {
log(`Could not find title element for: "${folderName}"`, this.plugin);
log(
`Could not find title element for: "${folderName}"`,
this.plugin,
);
continue;
}
// Get the folder element
const folderElement = titleContent.closest(".nav-folder");
if (!(folderElement instanceof HTMLElement)) {
log(`Could not find folder element for: "${folderName}"`, this.plugin);
log(
`Could not find folder element for: "${folderName}"`,
this.plugin,
);
continue;
}
// Check if it's collapsed
if (folderElement.classList.contains("is-collapsed")) {
log(`Folder is collapsed, clicking title to expand: "${folderName}"`, this.plugin);
log(
`Folder is collapsed, clicking title to expand: "${folderName}"`,
this.plugin,
);
// Click the title element directly - this is more reliable than the collapse indicator
titleElement.click();
return;
} else {
log(`Folder already expanded: "${folderName}"`, this.plugin);
log(
`Folder already expanded: "${folderName}"`,
this.plugin,
);
return;
}
}
}
log(`Could not find folder with name: "${folderName}"`, this.plugin);
log(
`Could not find folder with name: "${folderName}"`,
this.plugin,
);
} catch (error) {
logError(`Error expanding folder: ${error}`);
}
}
/**
* Highlight a folder in the DOM by finding and styling its title
*/
private highlightFolderInDOM(folderName: string): void {
try {
log(`Looking for folder to highlight: "${folderName}"`, this.plugin);
log(
`Looking for folder to highlight: "${folderName}"`,
this.plugin,
);
// Find all folder title content elements
const allTitleContents = document.querySelectorAll(".nav-folder-title-content");
const allTitleContents = document.querySelectorAll(
".nav-folder-title-content",
);
for (const titleContent of Array.from(allTitleContents)) {
if (titleContent.textContent === folderName) {
log(`Found folder to highlight: "${folderName}"`, this.plugin);
log(
`Found folder to highlight: "${folderName}"`,
this.plugin,
);
// Get the title element
const titleElement = titleContent.closest(".nav-folder-title");
const titleElement =
titleContent.closest(".nav-folder-title");
if (!(titleElement instanceof HTMLElement)) {
continue;
}
// Scroll into view
titleElement.scrollIntoView({
behavior: "smooth",
block: "center",
});
// Add highlighting class
titleElement.classList.add("nav-folder-title-highlighted");
// Remove after delay
setTimeout(() => {
titleElement.classList.remove("nav-folder-title-highlighted");
titleElement.classList.remove(
"nav-folder-title-highlighted",
);
}, 2000);
return;
}
}
log(`Could not find folder to highlight: "${folderName}"`, this.plugin);
log(
`Could not find folder to highlight: "${folderName}"`,
this.plugin,
);
} catch (error) {
logError(`Error highlighting folder: ${error}`);
}
}
// Override the renderSuggestion method to customize how folders are displayed
renderSuggestion(item: FuzzyMatch<TFolder>, el: HTMLElement): void {
// Check if this is a separator
if ((item.item as any)._isSeparator) {
el.empty();
const separatorEl = el.createDiv({
cls: "folder-separator",
});
separatorEl.setText((item.item as any)._separatorText);
return;
}
// 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,13 +1,35 @@
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,18 +1,171 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import {
App,
PluginSettingTab,
Setting,
DropdownComponent,
Modal,
Notice,
FuzzySuggestModal,
TFolder,
} 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")
@ -27,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(
@ -40,10 +286,13 @@ 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(

View file

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

@ -2,5 +2,10 @@
"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.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"
}