feat(settings): add folder picker for root path and vault folder settings

Attach an AbstractInputSuggest to the "Root path" and "Vault folder"
text inputs so typing shows a filtered dropdown of existing vault
folders, sourced from app.vault.getAllFolders(). Selecting a suggestion
fills the field and dispatches an input event so the existing
onChange/save/initializeGitService flow still fires. Free typing is
still accepted for paths that don't exist yet (e.g. a not-yet-created
repo subfolder for "Root path").

Closes #48

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BNWwPd5zudtW2JQr6ZAUm
This commit is contained in:
ClaudiaFang 2026-07-13 12:51:32 +00:00
parent ef238cea59
commit c107979427
3 changed files with 71 additions and 15 deletions

View file

@ -1,5 +1,6 @@
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
import GitLabFilesPush from "./main"; import GitLabFilesPush from "./main";
import {FolderSuggest} from "./ui/FolderSuggest";
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so // Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
// the plugin still type-checks against older Obsidian typings (minAppVersion // the plugin still type-checks against older Obsidian typings (minAppVersion
@ -162,25 +163,29 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName('Root path') .setName('Root path')
.setDesc('Optional: subfolder in repository (e.g. "notes")') .setDesc('Optional: subfolder in repository (e.g. "notes")')
.addText(text => text .addText(text => {
.setPlaceholder('Enter subfolder path') text.setPlaceholder('Enter subfolder path')
.setValue(this.plugin.settings.rootPath) .setValue(this.plugin.settings.rootPath)
.onChange((value) => { .onChange((value) => {
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, ''); this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
void this.plugin.saveSettings(); void this.plugin.saveSettings();
this.plugin.initializeGitService(); this.plugin.initializeGitService();
})); });
FolderSuggest.attach(this.app, text.inputEl);
});
new Setting(containerEl) new Setting(containerEl)
.setName('Vault folder') .setName('Vault folder')
.setDesc('Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)') .setDesc('Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)')
.addText(text => text .addText(text => {
.setPlaceholder('Leave empty to sync all files') text.setPlaceholder('Leave empty to sync all files')
.setValue(this.plugin.settings.vaultFolder) .setValue(this.plugin.settings.vaultFolder)
.onChange((value) => { .onChange((value) => {
this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, ''); this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, '');
void this.plugin.saveSettings(); void this.plugin.saveSettings();
})); });
FolderSuggest.attach(this.app, text.inputEl);
});
// "Real symlink" needs the Git Data API, which only GitHub offers. For // "Real symlink" needs the Git Data API, which only GitHub offers. For
// other providers, offer follow/skip only so the option can't mislead. // other providers, offer follow/skip only so the option can't mislead.

38
src/ui/FolderSuggest.ts Normal file
View file

@ -0,0 +1,38 @@
import {AbstractInputSuggest, App, TFolder} from 'obsidian';
/**
* Type-ahead folder suggester for a settings text input. Suggests existing
* vault folders but never forces a selection, since callers (e.g. the "Root
* path" repo setting) may need to accept a path that doesn't exist locally.
*/
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
constructor(app: App, private readonly inputEl: HTMLInputElement) {
super(app, inputEl);
}
protected getSuggestions(query: string): TFolder[] {
const lowerQuery = query.toLowerCase();
return this.app.vault.getAllFolders(true)
.filter(folder => folder.path.toLowerCase().contains(lowerQuery))
.sort((a, b) => a.path.localeCompare(b.path));
}
renderSuggestion(folder: TFolder, el: HTMLElement): void {
el.setText(folder.path === '/' ? '/' : folder.path);
}
selectSuggestion(folder: TFolder): void {
const path = folder.path === '/' ? '' : folder.path;
this.setValue(path);
// TextComponent listens for the native "input" event to fire onChange,
// so dispatch one to trigger the existing save/initializeGitService flow.
this.inputEl.dispatchEvent(new Event('input'));
this.close();
}
/** Attaches a FolderSuggest to `inputEl`; the instance self-registers via the base class, so the caller has nothing to hold onto. */
static attach(app: App, inputEl: HTMLInputElement): void {
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- AbstractInputSuggest wires itself to inputEl in its constructor; there's nothing to assign.
new FolderSuggest(app, inputEl);
}
}

View file

@ -54,6 +54,17 @@ export const App = class {
}; };
export const TFile = class {}; export const TFile = class {};
export const TFolder = class {};
export const AbstractInputSuggest = class {
app: unknown;
constructor(app: unknown) {
this.app = app;
}
setValue() {}
getValue() { return ''; }
close() {}
open() {}
};
export const requestUrl = vi.fn(); export const requestUrl = vi.fn();
export const setTooltip = vi.fn(); export const setTooltip = vi.fn();
export const setIcon = vi.fn(); export const setIcon = vi.fn();
@ -68,6 +79,8 @@ vi.mock('obsidian', () => ({
Editor, Editor,
App, App,
TFile, TFile,
TFolder,
AbstractInputSuggest,
requestUrl, requestUrl,
setTooltip, setTooltip,
setIcon, setIcon,