--!strict local vaultPath = "" local vaultName = "" local obsidianVaultId: string? = nil local vaultError: string? = nil local obsidianError: string? = nil local vaultEditMode = false local vaultEditPath = "" local RESULT_ROWS = 10 local notes: { any } = {} local indexedDirectories: { string } = {} local noteById: { [string]: any } = {} local queryFoldMap: { [string]: string } = {} local visible: { any } = {} local selected = 1 local previewTop = 1 local query = "" local currentDir = "" local readerMode = false local readerNote: any? = nil local createMode: string? = nil local createName = "" local createError: string? = nil local cutRelative: string? = nil local cutKind: string? = nil local deleteRelative: string? = nil local operationError: string? = nil local searchMode = false local loading = false local loadError: string? = nil local render: () -> () local refreshIndex: (string?, string?) -> () local validateVault: (string?, string?) -> () local function lowercase(value: any): string return string.lower(tostring(value or "")) end local function normalizeQuery(value: string): string local normalized = "" local index = 1 while index <= #value do local firstByte = string.byte(value, index) local width = 1 if firstByte >= 240 then width = 4 elseif firstByte >= 224 then width = 3 elseif firstByte >= 192 then width = 2 end local character = value:sub(index, index + width - 1) normalized ..= queryFoldMap[character] or lowercase(character) index += width end return normalized end local function fuzzyScore(text: string, needle: string): number? if needle == "" then return 0 end local score = 0 local cursor = 1 local previous = 0 for index = 1, #needle do local character = needle:sub(index, index) local found = text:find(character, cursor, true) if found == nil then return nil end score += 8 if found == previous + 1 then score += 12 end if found == 1 or text:sub(found - 1, found - 1):match("[%s%p]") then score += 10 end score -= math.min(found - cursor, 8) previous = found cursor = found + 1 end return score end local function fuzzyContentScore(note: any, term: string): number? if #term < 3 then return nil end local best: number? = nil local firstCharacter = term:sub(1, 1) local byLength = (note.contentTokens or {})[firstCharacter] or {} for length = #term, #term + 3 do for _, token in ipairs(byLength[tostring(length)] or {}) do local tokenScore = fuzzyScore(tostring(token), term) if tokenScore ~= nil and (best == nil or tokenScore > best) then best = tokenScore end end end return best end local function firstPosition(term: string, values: { any }): number? local best: number? = nil for _, value in ipairs(values) do local position = tostring(value or ""):find(term, 1, true) if position ~= nil and (best == nil or position < best) then best = position end end return best end local function scoreNote(note: any, rawQuery: string): number? local score = 0 for term in rawQuery:gmatch("%S+") do local titlePos = firstPosition(term, { note.titleLower, note.titleRaw }) local pathPos = firstPosition(term, { note.pathLower, note.pathRaw }) local contentPos = firstPosition(term, { note.contentLower, note.contentRaw }) if titlePos ~= nil then score += 500 - math.min(titlePos, 100) elseif pathPos ~= nil then score += 320 - math.min(pathPos, 100) elseif contentPos ~= nil then score += 90 - math.min(math.floor(contentPos / 200), 60) else local fuzzyTitle = fuzzyScore(note.titleLower, term) local fuzzyPath = fuzzyScore(note.pathLower, term) local fuzzyContent = fuzzyContentScore(note, term) if fuzzyTitle ~= nil then score += 180 + fuzzyTitle elseif fuzzyPath ~= nil then score += 100 + fuzzyPath elseif fuzzyContent ~= nil then score += 30 + fuzzyContent else return nil end end end return score end local function parentPath(path: string): string return path:match("^(.*)/[^/]+$") or "" end local function directoryEntries(): { any } local folders: { [string]: any } = {} local directNotes: { any } = {} local prefix = currentDir == "" and "" or currentDir .. "/" for _, directory in ipairs(indexedDirectories) do if directory:sub(1, #prefix) == prefix then local remainder = directory:sub(#prefix + 1) local child = remainder:match("^([^/]+)") if child ~= nil and child ~= "" then local relative = prefix .. child folders[relative] = folders[relative] or { kind = "folder", title = child, relative = relative, } end end end for _, note in ipairs(notes) do if note.relative:sub(1, #prefix) == prefix then local remainder = note.relative:sub(#prefix + 1) local child = remainder:match("^([^/]+)/") if child ~= nil then local relative = prefix .. child if folders[relative] == nil then folders[relative] = { kind = "folder", title = child, relative = relative, } end elseif remainder ~= "" then table.insert(directNotes, note) end end end local entries: { any } = {} for _, folder in pairs(folders) do table.insert(entries, folder) end table.sort(entries, function(a, b) return lowercase(a.title) < lowercase(b.title) end) table.sort(directNotes, function(a, b) return a.titleLower < b.titleLower end) for _, note in ipairs(directNotes) do table.insert(entries, note) end return entries end local function applyFilter() local normalized = normalizeQuery(query):match("^%s*(.-)%s*$") or "" if searchMode and normalized ~= "" then local ranked: { any } = {} for _, note in ipairs(notes) do local score = scoreNote(note, normalized) if score ~= nil then table.insert(ranked, { note = note, score = score }) end end table.sort(ranked, function(a, b) if a.score == b.score then return a.note.titleLower < b.note.titleLower end return a.score > b.score end) visible = {} for _, item in ipairs(ranked) do table.insert(visible, item.note) end else visible = directoryEntries() end selected = math.max(1, math.min(selected, math.max(#visible, 1))) previewTop = 1 end local function selectedEntry(): any? return visible[selected] end local function fullMarkdown(note: any): string local lines = note.lines or {} if #lines == 0 then return "_Empty note_" end return table.concat(lines, "\n") end local function setSelected(index: number) if #visible == 0 then return end selected = math.max(1, math.min(index, #visible)) previewTop = 1 render() end local function moveSelection(delta: number) setSelected(selected + delta) end local function launchObsidian(path: string) if vaultError ~= nil then operationError = vaultError render() return false end if obsidianVaultId == nil then operationError = obsidianError or "No matching vault is registered in Obsidian" render() return false end local script = (noctalia.pluginDir() or "") .. "/focus-obsidian.py" noctalia.runAsync({ "systemd-run", "--user", "--collect", "python3", script, vaultPath, obsidianVaultId, path, }) return true end local function openInObsidian(note: any) if launchObsidian(vaultPath .. "/" .. note.relative) then panel.close() end end local function enterReader(note: any) -- Inputs are uncontrolled and may retain keyboard focus after the mode that -- created them ends. Rebuild the panel tree without an input before reader -- mode so captured j/k chords return to the panel's onKey handler. searchMode = false createMode = nil vaultEditMode = false render() readerMode = true readerNote = note previewTop = 1 render() end local function leaveReader() readerMode = false readerNote = nil previewTop = 1 render() end local function selectRelative(relative: string) for index, entry in ipairs(visible) do if entry.relative == relative then selected = index return end end end local function createEntry(kind: string, name: string) createError = nil local script = (noctalia.pluginDir() or "") .. "/create-entry.py" noctalia.runAsync({ "python3", script, vaultPath, kind, currentDir, name }, function(result) if result.exitCode ~= 0 then createError = result.stderr ~= "" and result.stderr or "Creation failed" render() return end local created, decodeError = noctalia.json.decode(result.stdout) if type(created) ~= "table" or type(created.relative) ~= "string" or type(created.absolute) ~= "string" then createError = decodeError or "Creation returned invalid data" render() return end createMode = nil createName = "" if kind == "note" then if launchObsidian(created.absolute) then panel.close() end else refreshIndex(created.relative, "directory") end end, 10000) end local function submitCreate() if createMode ~= nil then createEntry(createMode, createName) end end local function beginCreate(kind: string) if vaultError ~= nil or vaultPath == "" then operationError = vaultError or "Configure a vault directory first" render() return end createMode = kind createName = "" createError = nil render() end local function manageEntry(arguments: { string }, callback: (any) -> ()) if vaultError ~= nil or vaultPath == "" then operationError = vaultError or "Configure a vault directory first" render() return end operationError = nil local script = (noctalia.pluginDir() or "") .. "/manage-entry.py" local command = { "python3", script, vaultPath } for _, argument in ipairs(arguments) do table.insert(command, argument) end noctalia.runAsync(command, function(result) if result.exitCode ~= 0 then operationError = result.stderr ~= "" and result.stderr or "File operation failed" render() return end callback(result) end, 10000) end local function cutSelected() local entry = selectedEntry() if entry == nil then return end cutRelative = entry.relative cutKind = entry.kind == "folder" and "directory" or "note" deleteRelative = nil operationError = nil render() end local function pasteCut() if cutRelative == nil then operationError = "Nothing is cut · press x on an entry first" render() return end manageEntry({ "move", cutRelative, currentDir }, function(result) local moved, decodeError = noctalia.json.decode(result.stdout) if type(moved) ~= "table" or type(moved.relative) ~= "string" then operationError = decodeError or "Move returned invalid data" render() return end local movedRelative = moved.relative local movedKind = cutKind cutRelative = nil cutKind = nil if movedKind == "directory" then refreshIndex(movedRelative, nil) else refreshIndex(movedRelative, nil) end end) end local function requestDelete() local entry = selectedEntry() if entry == nil then return end deleteRelative = entry.relative operationError = nil render() end local function confirmDelete() if deleteRelative == nil then return end local deleting = deleteRelative manageEntry({ "delete", deleting }, function(_result) deleteRelative = nil if cutRelative == deleting then cutRelative = nil cutKind = nil end refreshIndex() end) end local function enterSelected() local entry = selectedEntry() if entry == nil then return end if entry.kind == "folder" then currentDir = entry.relative selected = 1 previewTop = 1 applyFilter() render() else enterReader(entry) end end local function goParent() if currentDir == "" then return end local leaving = currentDir currentDir = parentPath(currentDir) selected = 1 applyFilter() for index, entry in ipairs(visible) do if entry.kind == "folder" and entry.relative == leaving then selected = index break end end previewTop = 1 render() end local function resultList(): any if loading then return ui.column({ width = 330, gap = 8, align = "stretch" }, { ui.label({ text = "Indexing vault…", color = "on_surface_variant" }), }) end if loadError ~= nil then return ui.column({ width = 330, gap = 8, align = "stretch" }, { ui.label({ text = "Index failed", color = "error", fontWeight = "bold" }), ui.label({ text = loadError, color = "on_surface_variant", maxLines = 8 }), }) end if #visible == 0 then return ui.column({ width = 330, gap = 8, align = "stretch" }, { ui.label({ text = "No matching notes", color = "on_surface_variant" }), }) end local rows: { any } = {} local first = math.max(1, math.min(selected - math.floor(RESULT_ROWS / 2), math.max(#visible - RESULT_ROWS + 1, 1))) local last = math.min(first + RESULT_ROWS - 1, #visible) for index = first, last do local entry = visible[index] local active = index == selected local folder = entry.kind == "folder" local rowProps: { [string]: any } = { key = tostring(entry.kind) .. "-" .. entry.relative, gap = 2, paddingH = 10, paddingV = 7, radius = 6, fill = active and "primary/0.18" or "surface/0", border = active and "primary/0.70" or "surface/0", borderWidth = active and 1 or 0, onClick = function() setSelected(index) end, } table.insert(rows, ui.column(rowProps, { ui.row({ align = "center", gap = 7 }, { ui.glyph({ name = folder and "folder" or "file-text", size = 15, color = folder and "primary" or "on_surface_variant" }), ui.label({ text = entry.title, fontWeight = active and "bold" or "normal", color = active and "primary" or "on_surface", maxLines = 1 }), }), ui.label({ text = folder and "Directory" or entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), })) end return ui.column({ width = 330, gap = 4, align = "stretch" }, rows) end local function previewPane(): any local entry = selectedEntry() if entry == nil then return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, { ui.label({ text = "Empty directory", color = "on_surface_variant" }), }) end if entry.kind == "folder" then local noteCount = 0 local childFolders: { [string]: boolean } = {} local prefix = entry.relative .. "/" for _, note in ipairs(notes) do if note.relative:sub(1, #prefix) == prefix then noteCount += 1 local remainder = note.relative:sub(#prefix + 1) local child = remainder:match("^([^/]+)/") if child ~= nil then childFolders[child] = true end end end local folderCount = 0 for _ in pairs(childFolders) do folderCount += 1 end return ui.column({ flexGrow = 1, align = "stretch", gap = 12 }, { ui.row({ align = "center", gap = 10 }, { ui.glyph({ name = "folder-open", size = 24, color = "primary" }), ui.column({ flexGrow = 1, gap = 2 }, { ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), }), ui.button({ text = "Enter", glyph = "arrow-right", variant = "primary", onClick = enterSelected }), }), ui.separator({ color = "outline" }), ui.label({ text = `{noteCount} notes · {folderCount} subfolders`, color = "on_surface_variant" }), ui.label({ text = "Press l or Enter to open this directory.", color = "on_surface_variant" }), }) end return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, { ui.row({ align = "center", justify = "space_between", gap = 8 }, { ui.column({ flexGrow = 1, gap = 2 }, { ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), }), ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function() openInObsidian(entry) end }), }), ui.separator({ color = "outline" }), ui.scroll({ flexGrow = 1, paddingH = 8, paddingV = 4, align = "stretch" }, { ui.markdown({ text = fullMarkdown(entry), flexGrow = 1 }), }), }) end local function readerPane(): any local note = readerNote if note == nil then return ui.column({ flexGrow = 1 }, {}) end return ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { ui.row({ align = "center", gap = 10 }, { ui.glyph({ name = "file-text", size = 21, color = "primary" }), ui.column({ flexGrow = 1, gap = 2 }, { ui.label({ text = note.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), ui.label({ text = note.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), }), ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function() openInObsidian(note) end }), }), ui.separator({ color = "outline" }), ui.scroll({ key = "reader-document", flexGrow = 1, paddingH = 10, paddingV = 6, align = "stretch", }, { ui.markdown({ text = fullMarkdown(note), flexGrow = 1 }), }), ui.separator({ color = "outline" }), ui.label({ text = "Mouse wheel or scrollbar to scroll · Ctrl-c finder · o open in Obsidian · Esc closes panel", fontSize = 11, color = "on_surface_variant", maxLines = 1, }), }) end render = function() if readerMode then -- A focused input receives printable keys before captured panel chords. -- Replacing the entire root with a keyed reader tree drops any stale input. panel.render(ui.column({ key = "reader-root", flexGrow = 1, align = "stretch" }, { readerPane(), })) return end local countText = loading and "Indexing…" or (`{#visible} entries · {#notes} notes`) local modeControl: any if vaultEditMode then modeControl = ui.input({ key = "vault-path-editor", width = 460, value = vaultEditPath, placeholder = "/path/to/Obsidian/vault", focus = true, submitOnEnter = true, onSubmit = function() validateVault(vaultEditPath, "save") end, onChange = function(value: string) vaultEditPath = value end, }) elseif createMode ~= nil then local label = createMode == "note" and "note" or "directory" modeControl = ui.input({ width = 320, value = createName, placeholder = "New " .. label .. " name…", focus = true, submitOnEnter = true, onSubmit = submitCreate, onChange = function(value: string) createName = value createError = nil end, }) elseif searchMode then modeControl = ui.input({ width = 280, value = query, placeholder = "Search vault…", focus = true, submitOnEnter = true, onSubmit = enterSelected, onChange = function(value: string) query = value selected = 1 applyFilter() render() end, }) else local modeText = query ~= "" and (`FILTER /{query}`) or "BROWSE" modeControl = ui.label({ text = modeText, color = "on_surface_variant", maxLines = 1 }) end panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { ui.row({ align = "center", gap = 10 }, { ui.glyph({ name = "notes", size = 20, color = "primary" }), ui.label({ text = "Knowledge Lookup", fontSize = 18, fontWeight = "bold", color = "on_surface" }), ui.label({ text = countText, color = "on_surface_variant" }), ui.spacer({ flexGrow = 1 }), modeControl, ui.button({ text = vaultEditMode and "Cancel" or "Change vault", glyph = "folder", variant = "ghost", onClick = function() vaultEditMode = not vaultEditMode vaultEditPath = vaultPath render() end }), ui.button({ glyph = "refresh", variant = "ghost", tooltip = "Re-index (r)", onClick = refreshIndex }), ui.button({ glyph = "close", variant = "ghost", tooltip = "Close (q)", onClick = function() panel.close() end }), }), ui.row({ align = "center", gap = 8 }, { ui.label({ text = "VAULT", fontSize = 10, fontWeight = "bold", color = "on_surface_variant" }), ui.label({ text = vaultPath ~= "" and vaultPath or "Not configured", color = vaultError and "error" or "primary", maxLines = 1 }), ui.label({ text = currentDir == "" and "/" or ("/" .. currentDir), color = "on_surface_variant", maxLines = 1 }), ui.spacer({ flexGrow = 1 }), ui.label({ text = obsidianVaultId and ("Obsidian: " .. vaultName) or "Obsidian: not registered", color = obsidianVaultId and "primary" or "on_surface_variant", maxLines = 1 }), }), ui.separator({ color = "outline" }), vaultError ~= nil and ui.column({ flexGrow = 1, gap = 14, align = "center", justify = "center" }, { ui.glyph({ name = "folder-x", size = 42, color = "error" }), ui.label({ text = "Vault unavailable", fontSize = 22, fontWeight = "bold", color = "error" }), ui.label({ text = vaultError, color = "on_surface_variant", maxLines = 3 }), ui.button({ text = "Change vault", glyph = "folder", variant = "primary", onClick = function() vaultEditMode = true vaultEditPath = vaultPath render() end }), }) or ui.row({ flexGrow = 1, gap = 14, align = "stretch" }, { resultList(), ui.separator({ orientation = "vertical", color = "outline" }), previewPane(), }), ui.separator({ color = "outline" }), ui.label({ text = operationError or vaultError or createError or obsidianError or (deleteRelative ~= nil and ("Delete “" .. deleteRelative .. "” and its contents? y confirm · n cancel")) or (createMode ~= nil and ("Enter creates this " .. (createMode == "note" and "note and opens it in Obsidian" or "directory"))) or (searchMode and "Global search: type title, path, or content · Enter read · Esc close") or (cutRelative ~= nil and ("CUT “" .. cutRelative .. "” · navigate with h/l, then p to move here")) or "j/k select · l/Enter open · h parent · x cut · p paste/move · d delete · Ctrl-n note · Ctrl-a dir · o Obsidian · q close", fontSize = 11, color = (operationError or vaultError or createError) and "error" or ((obsidianError or deleteRelative ~= nil) and "primary" or "on_surface_variant"), maxLines = 1, }), })) end local function mergeTokenBuckets(target: any, source: any) for firstCharacter, lengths in pairs(source or {}) do local targetLengths = target[firstCharacter] if targetLengths == nil then targetLengths = {} target[firstCharacter] = targetLengths end for length, tokens in pairs(lengths) do local targetTokens = targetLengths[length] if targetTokens == nil then targetTokens = {} targetLengths[length] = targetTokens end for _, token in ipairs(tokens) do table.insert(targetTokens, token) end end end end local function mergeIndexRecord(record: any) if type(record) ~= "table" then return end if record.kind == "directory" and type(record.relative) == "string" then table.insert(indexedDirectories, record.relative) return end if record.kind == "fold" then for source, target in pairs(record.map or {}) do queryFoldMap[tostring(source)] = tostring(target) end return end local id = tostring(record.id or "") if record.kind == "note" then local note = { kind = "note", title = tostring(record.title or record.relative or "Untitled"), relative = tostring(record.relative or ""), titleLower = tostring(record.titleSearch or lowercase(record.title)), titleRaw = tostring(record.titleRaw or lowercase(record.title)), pathLower = tostring(record.pathSearch or lowercase(record.relative)), pathRaw = tostring(record.pathRaw or lowercase(record.relative)), contentLower = "", contentRaw = "", contentTokens = {}, lines = {}, } noteById[id] = note table.insert(notes, note) return end if record.kind ~= "content" then return end local note = noteById[id] if note == nil then return end note.contentLower ..= tostring(record.contentSearch or "") note.contentRaw ..= tostring(record.contentRaw or "") for _, line in ipairs(record.lines or {}) do table.insert(note.lines, tostring(line)) end mergeTokenBuckets(note.contentTokens, record.contentTokens) end local function completeIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?) local directoryExists = currentDir == "" while not directoryExists and currentDir ~= "" do for _, directory in ipairs(indexedDirectories) do if directory == currentDir then directoryExists = true break end end if not directoryExists then currentDir = parentPath(currentDir) directoryExists = currentDir == "" end end loading = false selected = 1 applyFilter() if selectAfterRefresh ~= nil then selectRelative(selectAfterRefresh) if actionAfterRefresh == "directory" then currentDir = selectAfterRefresh selected = 1 applyFilter() end end render() end local function finishIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?) completeIndexLoad(selectAfterRefresh, actionAfterRefresh) end local function loadIndexChunks( chunkNames: { any }, index: number, selectAfterRefresh: string?, actionAfterRefresh: string? ) if index > #chunkNames then finishIndexLoad(selectAfterRefresh, actionAfterRefresh) return end local chunkName = chunkNames[index] if type(chunkName) ~= "string" or chunkName:match("^index%-cache%-%d%d%d%.json$") == nil then loading = false loadError = "Indexer returned an invalid chunk name" render() return end local chunkPath = (noctalia.pluginDir() or "") .. "/" .. chunkName local accepted = noctalia.readFileAsync(chunkPath, function(cacheJson, readError) if cacheJson == nil then loading = false loadError = readError or "Cannot read index cache" render() return end local decoded, decodeError = noctalia.json.decode(cacheJson) if type(decoded) ~= "table" then loading = false loadError = decodeError or "Index cache contains invalid JSON" render() return end for _, record in ipairs(decoded) do mergeIndexRecord(record) end loadIndexChunks(chunkNames, index + 1, selectAfterRefresh, actionAfterRefresh) end) if not accepted then loading = false loadError = "Cannot queue index cache read" render() end end refreshIndex = function(selectAfterRefresh: string?, actionAfterRefresh: string?) if vaultError ~= nil or vaultPath == "" then loading = false notes = {} indexedDirectories = {} visible = {} render() return end loading = true loadError = nil render() local pluginDir = noctalia.pluginDir() or "" local script = pluginDir .. "/index.py" local cache = pluginDir .. "/index-cache.json" noctalia.runAsync({ "python3", script, vaultPath, cache }, function(result) if result.exitCode ~= 0 then loading = false loadError = result.stderr ~= "" and result.stderr or "Indexer failed" render() return end local manifest, manifestError = noctalia.json.decode(result.stdout) if type(manifest) ~= "table" or type(manifest.chunks) ~= "table" then loading = false loadError = manifestError or "Indexer returned an invalid cache manifest" render() return end notes = {} indexedDirectories = {} noteById = {} queryFoldMap = {} loadIndexChunks(manifest.chunks, 1, selectAfterRefresh, actionAfterRefresh) end, 60000) end validateVault = function(candidate: string?, actionAfterValidation: string?) local configured = candidate or tostring(noctalia.getConfig("vault_path") or "") vaultEditMode = false loading = true vaultError = nil obsidianError = nil operationError = nil render() local script = (noctalia.pluginDir() or "") .. "/vault-info.py" noctalia.runAsync({ "python3", script, configured }, function(result) local info, decodeError = noctalia.json.decode(result.stdout) loading = false if type(info) ~= "table" or info.ok ~= true then vaultPath = configured vaultName = "" obsidianVaultId = nil vaultError = type(info) == "table" and tostring(info.error or "Vault validation failed") or decodeError or "Vault validation failed" notes = {} indexedDirectories = {} visible = {} render() return end vaultPath = tostring(info.path or "") vaultName = tostring(info.name or "") obsidianVaultId = type(info.vaultId) == "string" and info.vaultId or nil obsidianError = type(info.obsidianError) == "string" and info.obsidianError or nil currentDir = "" selected = 1 if actionAfterValidation == "save" then local dataDir = noctalia.pluginDataDir() if type(dataDir) == "string" then noctalia.writeFile(dataDir .. "/vault-path.txt", vaultPath) end end refreshIndex() end, 10000) end local function configuredVaultPath(): string local dataDir = noctalia.pluginDataDir() if type(dataDir) == "string" then local saved = noctalia.readFile(dataDir .. "/vault-path.txt") if type(saved) == "string" and saved ~= "" then return saved end end return tostring(noctalia.getConfig("vault_path") or "") end function onClose() end function onOpen(_context) -- A plugin runtime can outlive panel-close/open cycles, but user navigation -- must always reopen at the finder root. vaultEditMode = false vaultEditPath = "" searchMode = false createMode = nil createName = "" createError = nil cutRelative = nil cutKind = nil deleteRelative = nil operationError = nil readerMode = false readerNote = nil query = "" currentDir = "" selected = 1 previewTop = 1 validateVault(configuredVaultPath(), nil) end function onKey(chord: string, pressed: boolean) if not pressed then return end if readerMode then if chord == "o" and readerNote ~= nil then openInObsidian(readerNote) elseif chord == "ctrl+U0063" or chord == "escape" then leaveReader() end return end if vaultEditMode then if chord == "enter" then validateVault(vaultEditPath, "save") end return end if deleteRelative ~= nil then if chord == "y" then confirmDelete() elseif chord == "n" then deleteRelative = nil render() end return end -- Printable keys go to the focused input first, but Enter is explicitly -- captured by this panel and therefore must be submitted from onKey. if createMode ~= nil then if chord == "enter" then submitCreate() end return end if searchMode then if chord == "enter" then enterSelected() end return end if chord == "j" then moveSelection(1) elseif chord == "k" then moveSelection(-1) elseif chord == "l" or chord == "enter" then enterSelected() elseif chord == "h" then goParent() elseif chord == "g" then setSelected(1) elseif chord == "shift+U0047" then setSelected(#visible) elseif chord == "x" then cutSelected() elseif chord == "p" then pasteCut() elseif chord == "d" then requestDelete() elseif chord == "ctrl+U0061" then beginCreate("directory") elseif chord == "ctrl+U006e" then beginCreate("note") elseif chord == "slash" then searchMode = true query = "" selected = 1 applyFilter() render() elseif chord == "o" then local entry = selectedEntry() if entry ~= nil and entry.kind ~= "folder" then openInObsidian(entry) end elseif chord == "r" then refreshIndex() elseif chord == "q" or chord == "escape" then panel.close() end end