Folder Picker V1
This commit is contained in:
parent
1f9211cbc5
commit
ae0e699602
49
src/main.ts
49
src/main.ts
|
|
@ -22,6 +22,7 @@ import { buildSemanticMappings } from "./mapping/semanticMappingBuilder";
|
|||
import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal";
|
||||
import { GraphSchemaLoader } from "./schema/GraphSchemaLoader";
|
||||
import type { GraphSchema } from "./mapping/graphSchema";
|
||||
import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers";
|
||||
|
||||
export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||
settings: MindnetSettings;
|
||||
|
|
@ -79,14 +80,18 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
// Determine default folder (from profile defaults or settings)
|
||||
const defaultFolder = ""; // Will be set per profile selection
|
||||
|
||||
// Open profile selection modal
|
||||
new ProfileSelectionModal(
|
||||
this.app,
|
||||
config,
|
||||
async (result) => {
|
||||
await this.createNoteFromProfileAndOpen(result, basename);
|
||||
await this.createNoteFromProfileAndOpen(result, basename, result.folderPath);
|
||||
},
|
||||
title
|
||||
title,
|
||||
defaultFolder
|
||||
).open();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
|
@ -358,6 +363,9 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
|||
// Ignore errors getting initial title
|
||||
}
|
||||
|
||||
// Determine default folder (from profile defaults or settings)
|
||||
const defaultFolder = ""; // Will be set per profile selection
|
||||
|
||||
// Show modal
|
||||
new ProfileSelectionModal(
|
||||
this.app,
|
||||
|
|
@ -365,7 +373,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
|||
async (result) => {
|
||||
await this.createNoteFromProfile(result);
|
||||
},
|
||||
initialTitle
|
||||
initialTitle,
|
||||
defaultFolder
|
||||
).open();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
|
@ -429,8 +438,9 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async createNoteFromProfileAndOpen(
|
||||
result: { profile: import("./interview/types").InterviewProfile; title: string },
|
||||
preferredBasename?: string
|
||||
result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string },
|
||||
preferredBasename?: string,
|
||||
folderPath?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await this.ensureInterviewConfigLoaded();
|
||||
|
|
@ -462,23 +472,24 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
|||
// Create file content
|
||||
const content = `${frontmatter}\n\n`;
|
||||
|
||||
// Determine file path (use filenameBase directly, preserving spaces)
|
||||
// Handle name conflicts by appending " (2)", " (3)", etc. (Obsidian style)
|
||||
let fileName = `${filenameBase.trim()}.md`;
|
||||
let filePath = fileName;
|
||||
let attempt = 1;
|
||||
// Determine folder path (from result, parameter, profile defaults, or settings)
|
||||
const finalFolderPath = folderPath || result.folderPath ||
|
||||
(result.profile.defaults?.folder as string) ||
|
||||
this.settings.defaultNotesFolder ||
|
||||
"";
|
||||
|
||||
while (true) {
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (!existingFile) {
|
||||
break; // File doesn't exist, we can use this path
|
||||
}
|
||||
|
||||
attempt++;
|
||||
fileName = `${filenameBase.trim()} (${attempt}).md`;
|
||||
filePath = fileName;
|
||||
// Ensure folder exists
|
||||
if (finalFolderPath) {
|
||||
await ensureFolderExists(this.app, finalFolderPath);
|
||||
}
|
||||
|
||||
// Build file path
|
||||
const fileName = `${filenameBase.trim()}.md`;
|
||||
const desiredPath = joinFolderAndBasename(finalFolderPath, fileName);
|
||||
|
||||
// Ensure unique file path
|
||||
const filePath = await ensureUniqueFilePath(this.app, desiredPath);
|
||||
|
||||
// Create file
|
||||
const file = await this.app.vault.create(filePath, content);
|
||||
|
||||
|
|
|
|||
93
src/mapping/folderHelpers.ts
Normal file
93
src/mapping/folderHelpers.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Helper functions for folder and file path handling.
|
||||
*/
|
||||
|
||||
import { App } from "obsidian";
|
||||
import { normalizeVaultPath } from "../settings";
|
||||
|
||||
/**
|
||||
* Join folder path and basename to create full file path.
|
||||
* Handles empty folder (root) correctly.
|
||||
*/
|
||||
export function joinFolderAndBasename(folder: string, basename: string): string {
|
||||
const normalizedFolder = normalizeVaultPath(folder).trim();
|
||||
const normalizedBasename = basename.trim();
|
||||
|
||||
if (!normalizedFolder) {
|
||||
// Root folder
|
||||
return normalizedBasename;
|
||||
}
|
||||
|
||||
// Remove trailing slash if present
|
||||
const cleanFolder = normalizedFolder.replace(/\/$/, "");
|
||||
|
||||
return `${cleanFolder}/${normalizedBasename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure unique file path by appending (2), (3), etc. if file exists.
|
||||
* Returns the first available path.
|
||||
*/
|
||||
export async function ensureUniqueFilePath(
|
||||
app: App,
|
||||
desiredPath: string
|
||||
): Promise<string> {
|
||||
const normalizedPath = normalizeVaultPath(desiredPath);
|
||||
|
||||
// Check if file exists
|
||||
const existingFile = app.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!existingFile) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
// Extract directory and basename
|
||||
const lastSlash = normalizedPath.lastIndexOf("/");
|
||||
const dir = lastSlash >= 0 ? normalizedPath.substring(0, lastSlash) : "";
|
||||
const fullBasename = lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath;
|
||||
|
||||
// Extract name and extension
|
||||
const lastDot = fullBasename.lastIndexOf(".");
|
||||
const name = lastDot >= 0 ? fullBasename.substring(0, lastDot) : fullBasename;
|
||||
const ext = lastDot >= 0 ? fullBasename.substring(lastDot) : "";
|
||||
|
||||
// Try (2), (3), etc.
|
||||
let attempt = 2;
|
||||
while (attempt < 1000) {
|
||||
const newBasename = `${name} (${attempt})${ext}`;
|
||||
const newPath = dir ? `${dir}/${newBasename}` : newBasename;
|
||||
|
||||
const existing = app.vault.getAbstractFileByPath(newPath);
|
||||
if (!existing) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
// Fallback: add timestamp
|
||||
const timestamp = Date.now();
|
||||
const newBasename = `${name}_${timestamp}${ext}`;
|
||||
return dir ? `${dir}/${newBasename}` : newBasename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure folder exists, creating it if necessary (mkdirp).
|
||||
*/
|
||||
export async function ensureFolderExists(app: App, folderPath: string): Promise<void> {
|
||||
const normalizedPath = normalizeVaultPath(folderPath).trim();
|
||||
|
||||
if (!normalizedPath) {
|
||||
// Root folder, always exists
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if folder exists
|
||||
const existing = app.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (existing) {
|
||||
// Already exists
|
||||
return;
|
||||
}
|
||||
|
||||
// Create folder (mkdirp)
|
||||
await app.vault.createFolder(normalizedPath);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export interface MindnetSettings {
|
|||
defaultEdgeType: string; // default: ""
|
||||
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
|
||||
allowOverwriteExistingMappings: boolean; // default: false
|
||||
defaultNotesFolder: string; // default: "" (vault root)
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MindnetSettings = {
|
||||
|
|
@ -33,6 +34,7 @@ export interface MindnetSettings {
|
|||
defaultEdgeType: "",
|
||||
unassignedHandling: "prompt",
|
||||
allowOverwriteExistingMappings: false,
|
||||
defaultNotesFolder: "",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
36
src/tests/mapping/folderHelpers.test.ts
Normal file
36
src/tests/mapping/folderHelpers.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Unit tests for folder helpers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { joinFolderAndBasename, ensureUniqueFilePath } from "../../mapping/folderHelpers";
|
||||
|
||||
describe("joinFolderAndBasename", () => {
|
||||
it("should join folder and basename", () => {
|
||||
expect(joinFolderAndBasename("folder1/folder2", "note.md")).toBe("folder1/folder2/note.md");
|
||||
});
|
||||
|
||||
it("should handle empty folder (root)", () => {
|
||||
expect(joinFolderAndBasename("", "note.md")).toBe("note.md");
|
||||
});
|
||||
|
||||
it("should handle folder with trailing slash", () => {
|
||||
expect(joinFolderAndBasename("folder/", "note.md")).toBe("folder/note.md");
|
||||
});
|
||||
|
||||
it("should normalize paths", () => {
|
||||
expect(joinFolderAndBasename("folder1\\folder2", "note.md")).toBe("folder1/folder2/note.md");
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(joinFolderAndBasename(" folder ", " note.md ")).toBe("folder/note.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUniqueFilePath", () => {
|
||||
// Note: Full tests would require mocking App and vault
|
||||
// These are basic structure tests
|
||||
it("should be a function", () => {
|
||||
expect(typeof ensureUniqueFilePath).toBe("function");
|
||||
});
|
||||
});
|
||||
461
src/ui/FolderTreeModal.ts
Normal file
461
src/ui/FolderTreeModal.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
/**
|
||||
* Modal for selecting a folder from the vault.
|
||||
*/
|
||||
|
||||
import { Modal, Notice } from "obsidian";
|
||||
import { buildFolderTree } from "../entityPicker/folderTree";
|
||||
import { NoteIndex } from "../entityPicker/noteIndex";
|
||||
import type { FolderTreeNode } from "../entityPicker/types";
|
||||
|
||||
export interface FolderTreeResult {
|
||||
folderPath: string; // Vault-relative path, empty string for root
|
||||
}
|
||||
|
||||
interface FolderNodeState {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export class FolderTreeModal extends Modal {
|
||||
private result: FolderTreeResult | null = null;
|
||||
private resolve: ((result: FolderTreeResult | null) => void) | null = null;
|
||||
private noteIndex: NoteIndex;
|
||||
private selectedFolderPath: string = ""; // Empty = root
|
||||
private nodeStates: Map<string, FolderNodeState> = new Map(); // path -> state
|
||||
|
||||
constructor(app: any, noteIndex: NoteIndex, initialFolderPath: string = "") {
|
||||
super(app);
|
||||
this.noteIndex = noteIndex;
|
||||
this.selectedFolderPath = initialFolderPath;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass("folder-tree-modal");
|
||||
|
||||
contentEl.createEl("h2", { text: "Select folder" });
|
||||
|
||||
// Build folder tree from note paths AND empty folders
|
||||
const allEntries = this.noteIndex.getAllEntries();
|
||||
|
||||
// Also get all folders from vault (including empty ones)
|
||||
// Use vault.adapter to get all folders
|
||||
const folderPaths = new Set<string>();
|
||||
|
||||
// Add folders from note entries
|
||||
for (const entry of allEntries) {
|
||||
if (entry.folder) {
|
||||
folderPaths.add(entry.folder);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all folders from vault (including empty)
|
||||
// Iterate through vault's abstract files to find folders
|
||||
const allAbstractFiles = this.app.vault.getAllFolders();
|
||||
for (const folder of allAbstractFiles) {
|
||||
if (folder.path) {
|
||||
folderPaths.add(folder.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to NoteIndexEntry format for buildFolderTree
|
||||
// We need to create entries for folders that don't have notes
|
||||
const folderEntries: Array<import("../entityPicker/types").NoteIndexEntry> = [];
|
||||
for (const folderPath of folderPaths) {
|
||||
// Check if we already have entries for this folder
|
||||
const hasEntries = allEntries.some((e) => e.folder === folderPath);
|
||||
if (!hasEntries) {
|
||||
// Create a dummy entry for empty folder
|
||||
folderEntries.push({
|
||||
path: folderPath,
|
||||
folder: folderPath,
|
||||
basename: folderPath.split("/").pop() || "",
|
||||
title: null,
|
||||
type: null,
|
||||
mtime: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Combine with note entries
|
||||
const combinedEntries = [...allEntries, ...folderEntries];
|
||||
const folderTree = buildFolderTree(combinedEntries);
|
||||
|
||||
// Current selection display
|
||||
const currentDisplay = contentEl.createEl("div", { cls: "current-folder" });
|
||||
currentDisplay.style.marginBottom = "1em";
|
||||
currentDisplay.style.padding = "0.5em";
|
||||
currentDisplay.style.backgroundColor = "var(--background-secondary)";
|
||||
currentDisplay.style.borderRadius = "4px";
|
||||
|
||||
const currentLabel = currentDisplay.createEl("span", {
|
||||
text: "Current: ",
|
||||
cls: "current-label",
|
||||
});
|
||||
const currentPath = currentDisplay.createEl("span", {
|
||||
text: this.selectedFolderPath || "(root)",
|
||||
cls: "current-path",
|
||||
});
|
||||
currentPath.style.fontWeight = "bold";
|
||||
|
||||
// Tree container
|
||||
const treeContainer = contentEl.createEl("div", { cls: "folder-tree-container" });
|
||||
treeContainer.style.maxHeight = "400px";
|
||||
treeContainer.style.overflowY = "auto";
|
||||
treeContainer.style.border = "1px solid var(--background-modifier-border)";
|
||||
treeContainer.style.borderRadius = "4px";
|
||||
treeContainer.style.padding = "0.5em";
|
||||
|
||||
// Store scroll position before rendering (if container already exists)
|
||||
const existingContainer = contentEl.querySelector(".folder-tree-container");
|
||||
const scrollTop = existingContainer instanceof HTMLElement ? existingContainer.scrollTop : 0;
|
||||
|
||||
// Render tree
|
||||
this.renderFolderTree(folderTree, treeContainer);
|
||||
|
||||
// Scroll to selected folder or restore position
|
||||
setTimeout(() => {
|
||||
if (this.selectedFolderPath) {
|
||||
// Scroll to selected folder
|
||||
const selectedElement = treeContainer.querySelector(`[data-folder-path="${this.selectedFolderPath}"]`);
|
||||
if (selectedElement && selectedElement instanceof HTMLElement) {
|
||||
selectedElement.scrollIntoView({ behavior: "auto", block: "center" });
|
||||
}
|
||||
} else if (scrollTop > 0) {
|
||||
// Restore previous scroll position
|
||||
treeContainer.scrollTop = scrollTop;
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Buttons
|
||||
const buttonContainer = contentEl.createEl("div");
|
||||
buttonContainer.style.display = "flex";
|
||||
buttonContainer.style.gap = "0.5em";
|
||||
buttonContainer.style.justifyContent = "flex-end";
|
||||
buttonContainer.style.marginTop = "1em";
|
||||
|
||||
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" });
|
||||
cancelBtn.onclick = () => {
|
||||
this.result = null;
|
||||
this.close();
|
||||
};
|
||||
|
||||
const selectRootBtn = buttonContainer.createEl("button", {
|
||||
text: "Select root",
|
||||
});
|
||||
selectRootBtn.onclick = () => {
|
||||
this.selectedFolderPath = "";
|
||||
this.result = { folderPath: "" };
|
||||
this.close();
|
||||
};
|
||||
|
||||
const newRootFolderBtn = buttonContainer.createEl("button", {
|
||||
text: "New folder in root…",
|
||||
});
|
||||
newRootFolderBtn.onclick = async () => {
|
||||
const folderName = await this.promptForFolderName();
|
||||
if (folderName && folderName.trim()) {
|
||||
try {
|
||||
await this.app.vault.createFolder(folderName.trim());
|
||||
new Notice(`Folder created: ${folderName.trim()}`);
|
||||
|
||||
// Refresh note index
|
||||
this.noteIndex.refresh();
|
||||
|
||||
// Re-render
|
||||
this.onOpen();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to create folder: ${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBtn = buttonContainer.createEl("button", {
|
||||
text: "Confirm",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
confirmBtn.onclick = () => {
|
||||
this.result = { folderPath: this.selectedFolderPath };
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
|
||||
private renderFolderTree(
|
||||
tree: FolderTreeNode,
|
||||
container: HTMLElement,
|
||||
level: number = 0
|
||||
): void {
|
||||
// Skip root node (empty path), render its children
|
||||
if (tree.path === "") {
|
||||
// Render root's children
|
||||
for (const child of tree.children) {
|
||||
this.renderFolderNode(child, container, level);
|
||||
}
|
||||
} else {
|
||||
// Render this node
|
||||
this.renderFolderNode(tree, container, level);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFolderNode(
|
||||
node: FolderTreeNode,
|
||||
container: HTMLElement,
|
||||
level: number
|
||||
): void {
|
||||
const isSelected = node.path === this.selectedFolderPath;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const state = this.nodeStates.get(node.path) || { expanded: level < 2 }; // Auto-expand first 2 levels
|
||||
if (!this.nodeStates.has(node.path)) {
|
||||
this.nodeStates.set(node.path, state);
|
||||
}
|
||||
|
||||
const folderItem = container.createEl("div", {
|
||||
cls: `folder-item ${isSelected ? "is-selected" : ""}`,
|
||||
});
|
||||
folderItem.setAttribute("data-folder-path", node.path);
|
||||
folderItem.style.display = "flex";
|
||||
folderItem.style.alignItems = "center";
|
||||
folderItem.style.padding = "0.25em 0.5em";
|
||||
folderItem.style.cursor = "pointer";
|
||||
folderItem.style.borderRadius = "4px";
|
||||
folderItem.style.marginLeft = `${level * 1.5}em`;
|
||||
|
||||
if (isSelected) {
|
||||
folderItem.style.backgroundColor = "var(--interactive-hover)";
|
||||
folderItem.style.fontWeight = "bold";
|
||||
}
|
||||
|
||||
folderItem.onmouseenter = () => {
|
||||
if (!isSelected) {
|
||||
folderItem.style.backgroundColor = "var(--background-modifier-hover)";
|
||||
}
|
||||
};
|
||||
folderItem.onmouseleave = () => {
|
||||
if (!isSelected) {
|
||||
folderItem.style.backgroundColor = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Expand/collapse button
|
||||
const expandBtn = folderItem.createEl("span", {
|
||||
text: hasChildren ? (state.expanded ? "▼" : "▶") : " ",
|
||||
cls: "expand-icon",
|
||||
});
|
||||
expandBtn.style.width = "1em";
|
||||
expandBtn.style.marginRight = "0.25em";
|
||||
expandBtn.style.fontSize = "0.8em";
|
||||
expandBtn.style.cursor = hasChildren ? "pointer" : "default";
|
||||
expandBtn.style.userSelect = "none";
|
||||
|
||||
if (hasChildren) {
|
||||
expandBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
state.expanded = !state.expanded;
|
||||
this.onOpen(); // Re-render
|
||||
};
|
||||
}
|
||||
|
||||
// Folder icon and name
|
||||
const folderLabel = folderItem.createEl("span", {
|
||||
text: `📁 ${node.name}`,
|
||||
});
|
||||
folderLabel.style.flex = "1";
|
||||
|
||||
// Note count
|
||||
if (node.noteCount > 0) {
|
||||
const countSpan = folderItem.createEl("span", {
|
||||
text: ` (${node.noteCount})`,
|
||||
cls: "note-count",
|
||||
});
|
||||
countSpan.style.color = "var(--text-muted)";
|
||||
countSpan.style.fontSize = "0.9em";
|
||||
countSpan.style.marginRight = "0.5em";
|
||||
}
|
||||
|
||||
// Action buttons container
|
||||
const actionsContainer = folderItem.createEl("div", {
|
||||
cls: "folder-actions",
|
||||
});
|
||||
actionsContainer.style.display = "flex";
|
||||
actionsContainer.style.gap = "0.25em";
|
||||
actionsContainer.style.opacity = "0";
|
||||
actionsContainer.style.transition = "opacity 0.2s";
|
||||
|
||||
folderItem.onmouseenter = () => {
|
||||
if (!isSelected) {
|
||||
folderItem.style.backgroundColor = "var(--background-modifier-hover)";
|
||||
}
|
||||
actionsContainer.style.opacity = "1";
|
||||
};
|
||||
folderItem.onmouseleave = () => {
|
||||
if (!isSelected) {
|
||||
folderItem.style.backgroundColor = "";
|
||||
}
|
||||
actionsContainer.style.opacity = "0";
|
||||
};
|
||||
|
||||
// Select button
|
||||
const selectBtn = actionsContainer.createEl("button", {
|
||||
text: "Select",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
selectBtn.style.fontSize = "0.85em";
|
||||
selectBtn.style.padding = "0.2em 0.5em";
|
||||
selectBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectedFolderPath = node.path;
|
||||
// Update display without full re-render to preserve scroll
|
||||
this.updateSelectionDisplay();
|
||||
};
|
||||
|
||||
// New folder button
|
||||
const newFolderBtn = actionsContainer.createEl("button", {
|
||||
text: "+",
|
||||
title: "Create subfolder",
|
||||
});
|
||||
newFolderBtn.style.fontSize = "0.9em";
|
||||
newFolderBtn.style.padding = "0.2em 0.4em";
|
||||
newFolderBtn.style.minWidth = "1.5em";
|
||||
newFolderBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const folderName = await this.promptForFolderName();
|
||||
if (folderName && folderName.trim()) {
|
||||
const newFolderPath = node.path
|
||||
? `${node.path}/${folderName.trim()}`
|
||||
: folderName.trim();
|
||||
|
||||
try {
|
||||
await this.app.vault.createFolder(newFolderPath);
|
||||
new Notice(`Folder created: ${newFolderPath}`);
|
||||
|
||||
// Refresh note index
|
||||
this.noteIndex.refresh();
|
||||
|
||||
// Expand parent and select new folder
|
||||
state.expanded = true;
|
||||
this.selectedFolderPath = newFolderPath;
|
||||
|
||||
// Re-render (will scroll to selected folder)
|
||||
this.onOpen();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to create folder: ${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Main click: select folder
|
||||
folderItem.onclick = () => {
|
||||
this.selectedFolderPath = node.path;
|
||||
// Update display without full re-render to preserve scroll
|
||||
this.updateSelectionDisplay();
|
||||
};
|
||||
|
||||
// Render children if expanded
|
||||
if (hasChildren && state.expanded) {
|
||||
const childrenContainer = container.createEl("div", {
|
||||
cls: "folder-children",
|
||||
});
|
||||
for (const child of node.children) {
|
||||
this.renderFolderNode(child, childrenContainer, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForFolderName(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal(this.app);
|
||||
modal.titleEl.textContent = "New folder name";
|
||||
|
||||
const inputContainer = modal.contentEl.createEl("div");
|
||||
const input = inputContainer.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "Folder name",
|
||||
});
|
||||
input.style.width = "100%";
|
||||
input.style.marginBottom = "1em";
|
||||
|
||||
const buttonContainer = modal.contentEl.createEl("div");
|
||||
buttonContainer.style.display = "flex";
|
||||
buttonContainer.style.gap = "0.5em";
|
||||
buttonContainer.style.justifyContent = "flex-end";
|
||||
|
||||
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" });
|
||||
cancelBtn.onclick = () => {
|
||||
modal.close();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const createBtn = buttonContainer.createEl("button", {
|
||||
text: "Create",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
createBtn.onclick = () => {
|
||||
const value = input.value.trim();
|
||||
modal.close();
|
||||
resolve(value || null);
|
||||
};
|
||||
|
||||
input.onkeydown = (evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
createBtn.click();
|
||||
} else if (evt.key === "Escape") {
|
||||
cancelBtn.click();
|
||||
}
|
||||
};
|
||||
|
||||
modal.open();
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
private updateSelectionDisplay(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
// Update current selection display
|
||||
const currentPath = contentEl.querySelector(".current-path");
|
||||
if (currentPath) {
|
||||
currentPath.textContent = this.selectedFolderPath || "(root)";
|
||||
}
|
||||
|
||||
// Update selected state in tree
|
||||
const allItems = Array.from(contentEl.querySelectorAll(".folder-item"));
|
||||
for (const item of allItems) {
|
||||
const itemPath = item.getAttribute("data-folder-path");
|
||||
if (itemPath === this.selectedFolderPath) {
|
||||
item.addClass("is-selected");
|
||||
if (item instanceof HTMLElement) {
|
||||
item.style.backgroundColor = "var(--interactive-hover)";
|
||||
item.style.fontWeight = "bold";
|
||||
}
|
||||
} else {
|
||||
item.removeClass("is-selected");
|
||||
if (item instanceof HTMLElement) {
|
||||
item.style.backgroundColor = "";
|
||||
item.style.fontWeight = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
if (this.resolve) {
|
||||
this.resolve(this.result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show modal and return promise that resolves with user's choice.
|
||||
*/
|
||||
async show(): Promise<FolderTreeResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.resolve = resolve;
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -312,5 +312,19 @@ export class MindnetSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// Default notes folder
|
||||
new Setting(containerEl)
|
||||
.setName("Default notes folder")
|
||||
.setDesc("Default folder for new notes (vault-relative path, empty for root). Profile defaults take precedence.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(this.plugin.settings.defaultNotesFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultNotesFolder = value || "";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { App, Modal, Notice, Setting, TFile } from "obsidian";
|
||||
import type { InterviewConfig, InterviewProfile } from "../interview/types";
|
||||
import { FolderTreeModal, type FolderTreeResult } from "./FolderTreeModal";
|
||||
import { NoteIndex } from "../entityPicker/noteIndex";
|
||||
|
||||
export interface ProfileSelectionResult {
|
||||
profile: InterviewProfile;
|
||||
title: string;
|
||||
folderPath: string; // Vault-relative path, empty string for root
|
||||
}
|
||||
|
||||
export class ProfileSelectionModal extends Modal {
|
||||
|
|
@ -11,17 +14,22 @@ export class ProfileSelectionModal extends Modal {
|
|||
onSubmit: (result: ProfileSelectionResult) => void;
|
||||
config: InterviewConfig;
|
||||
initialTitle: string;
|
||||
defaultFolderPath: string;
|
||||
private noteIndex: NoteIndex;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
config: InterviewConfig,
|
||||
onSubmit: (result: ProfileSelectionResult) => void,
|
||||
initialTitle: string = ""
|
||||
initialTitle: string = "",
|
||||
defaultFolderPath: string = ""
|
||||
) {
|
||||
super(app);
|
||||
this.config = config;
|
||||
this.onSubmit = onSubmit;
|
||||
this.initialTitle = initialTitle;
|
||||
this.defaultFolderPath = defaultFolderPath;
|
||||
this.noteIndex = new NoteIndex(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
|
|
@ -50,6 +58,7 @@ export class ProfileSelectionModal extends Modal {
|
|||
|
||||
let selectedProfile: InterviewProfile | null = null;
|
||||
let titleInput: string = this.initialTitle;
|
||||
let folderPath: string = this.defaultFolderPath;
|
||||
|
||||
// Title input
|
||||
const titleSetting = new Setting(contentEl)
|
||||
|
|
@ -63,6 +72,65 @@ export class ProfileSelectionModal extends Modal {
|
|||
text.inputEl.select();
|
||||
});
|
||||
|
||||
// Folder selection
|
||||
const folderDisplay = contentEl.createEl("div", { cls: "folder-display" });
|
||||
folderDisplay.style.marginBottom = "1em";
|
||||
folderDisplay.style.padding = "0.5em";
|
||||
folderDisplay.style.backgroundColor = "var(--background-secondary)";
|
||||
folderDisplay.style.borderRadius = "4px";
|
||||
|
||||
const folderLabel = folderDisplay.createEl("span", {
|
||||
text: "Folder: ",
|
||||
cls: "folder-label",
|
||||
});
|
||||
const folderPathSpan = folderDisplay.createEl("span", {
|
||||
text: folderPath || "(root)",
|
||||
cls: "folder-path",
|
||||
});
|
||||
folderPathSpan.style.fontWeight = "bold";
|
||||
|
||||
const folderButtonContainer = folderDisplay.createEl("div");
|
||||
folderButtonContainer.style.display = "flex";
|
||||
folderButtonContainer.style.gap = "0.5em";
|
||||
folderButtonContainer.style.marginTop = "0.5em";
|
||||
|
||||
const pickFolderBtn = folderButtonContainer.createEl("button", {
|
||||
text: "Pick…",
|
||||
});
|
||||
pickFolderBtn.onclick = async () => {
|
||||
const folderModal = new FolderTreeModal(this.app, this.noteIndex, folderPath);
|
||||
const result = await folderModal.show();
|
||||
if (result) {
|
||||
folderPath = result.folderPath;
|
||||
folderPathSpan.textContent = folderPath || "(root)";
|
||||
}
|
||||
};
|
||||
|
||||
const newFolderBtn = folderButtonContainer.createEl("button", {
|
||||
text: "New folder…",
|
||||
});
|
||||
newFolderBtn.onclick = async () => {
|
||||
const folderName = await this.promptForFolderName();
|
||||
if (folderName && folderName.trim()) {
|
||||
const newFolderPath = folderPath
|
||||
? `${folderPath}/${folderName.trim()}`
|
||||
: folderName.trim();
|
||||
|
||||
try {
|
||||
await this.app.vault.createFolder(newFolderPath);
|
||||
folderPath = newFolderPath;
|
||||
folderPathSpan.textContent = folderPath || "(root)";
|
||||
new Notice(`Folder created: ${newFolderPath}`);
|
||||
|
||||
// Refresh note index to include new folder
|
||||
this.noteIndex.refresh();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
new Notice(`Failed to create folder: ${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Profile selection (grouped)
|
||||
for (const [groupName, profiles] of grouped.entries()) {
|
||||
contentEl.createEl("h3", { text: groupName });
|
||||
|
|
@ -83,12 +151,18 @@ export class ProfileSelectionModal extends Modal {
|
|||
setting.settingEl.addClass("profile-selected");
|
||||
selectedProfile = profile;
|
||||
|
||||
// Update folder path from profile defaults
|
||||
const profileFolder = (profile.defaults?.folder as string) || this.defaultFolderPath || "";
|
||||
folderPath = profileFolder;
|
||||
folderPathSpan.textContent = folderPath || "(root)";
|
||||
|
||||
// Log selection
|
||||
console.log("Profile selected", {
|
||||
key: profile.key,
|
||||
label: profile.label,
|
||||
noteType: profile.note_type,
|
||||
stepCount: profile.steps?.length || 0,
|
||||
defaultFolder: profileFolder,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -117,12 +191,18 @@ export class ProfileSelectionModal extends Modal {
|
|||
setting.settingEl.addClass("profile-selected");
|
||||
selectedProfile = profile;
|
||||
|
||||
// Update folder path from profile defaults
|
||||
const profileFolder = (profile.defaults?.folder as string) || this.defaultFolderPath || "";
|
||||
folderPath = profileFolder;
|
||||
folderPathSpan.textContent = folderPath || "(root)";
|
||||
|
||||
// Log selection
|
||||
console.log("Profile selected", {
|
||||
key: profile.key,
|
||||
label: profile.label,
|
||||
noteType: profile.note_type,
|
||||
stepCount: profile.steps?.length || 0,
|
||||
defaultFolder: profileFolder,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -147,6 +227,7 @@ export class ProfileSelectionModal extends Modal {
|
|||
this.result = {
|
||||
profile: selectedProfile,
|
||||
title: titleInput.trim(),
|
||||
folderPath: folderPath.trim(),
|
||||
};
|
||||
this.onSubmit(this.result);
|
||||
this.close();
|
||||
|
|
@ -154,6 +235,54 @@ export class ProfileSelectionModal extends Modal {
|
|||
});
|
||||
}
|
||||
|
||||
private async promptForFolderName(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal(this.app);
|
||||
modal.titleEl.textContent = "New folder name";
|
||||
|
||||
const inputContainer = modal.contentEl.createEl("div");
|
||||
const input = inputContainer.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "Folder name",
|
||||
});
|
||||
input.style.width = "100%";
|
||||
input.style.marginBottom = "1em";
|
||||
|
||||
const buttonContainer = modal.contentEl.createEl("div");
|
||||
buttonContainer.style.display = "flex";
|
||||
buttonContainer.style.gap = "0.5em";
|
||||
buttonContainer.style.justifyContent = "flex-end";
|
||||
|
||||
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" });
|
||||
cancelBtn.onclick = () => {
|
||||
modal.close();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const createBtn = buttonContainer.createEl("button", {
|
||||
text: "Create",
|
||||
cls: "mod-cta",
|
||||
});
|
||||
createBtn.onclick = () => {
|
||||
const value = input.value.trim();
|
||||
modal.close();
|
||||
resolve(value || null);
|
||||
};
|
||||
|
||||
input.onkeydown = (evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
createBtn.click();
|
||||
} else if (evt.key === "Escape") {
|
||||
cancelBtn.click();
|
||||
}
|
||||
};
|
||||
|
||||
modal.open();
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user