diff --git a/package-lock.json b/package-lock.json index d0dac39..1dbbda1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-symlink-manager", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "obsidian-sample-plugin", + "name": "obsidian-symlink-manager", "version": "1.0.0", "license": "0-BSD", "dependencies": { @@ -13,7 +13,7 @@ }, "devDependencies": { "@eslint/js": "9.30.1", - "@types/node": "^16.11.6", + "@types/node": "^16.18.126", "esbuild": "0.25.5", "eslint-plugin-obsidianmd": "0.1.9", "globals": "14.0.0", @@ -1044,9 +1044,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1054,13 +1054,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1148,9 +1148,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1381,9 +1381,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2188,9 +2188,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2205,9 +2205,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2222,9 +2222,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", "dev": true, "license": "ISC", "dependencies": { @@ -2264,9 +2264,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2287,13 +2287,13 @@ } }, "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2691,9 +2691,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3522,9 +3522,9 @@ } }, "node_modules/json-schema-migrate/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -3746,9 +3746,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4044,9 +4044,9 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5101,9 +5101,9 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -5111,6 +5111,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yaml-eslint-parser": { diff --git a/package.json b/package.json index 17268d7..9c9c163 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-symlink-manager", "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "description": "Right-click any folder to create, edit, or disconnect junctions and symbolic links from inside Obsidian.", "main": "main.js", "type": "module", "scripts": { @@ -13,15 +13,15 @@ "keywords": [], "license": "0-BSD", "devDependencies": { - "@types/node": "^16.11.6", + "@eslint/js": "9.30.1", + "@types/node": "^16.18.126", "esbuild": "0.25.5", "eslint-plugin-obsidianmd": "0.1.9", "globals": "14.0.0", + "jiti": "2.6.1", "tslib": "2.4.0", "typescript": "^5.8.3", - "typescript-eslint": "8.35.1", - "@eslint/js": "9.30.1", - "jiti": "2.6.1" + "typescript-eslint": "8.35.1" }, "dependencies": { "obsidian": "latest" diff --git a/src/badges.ts b/src/badges.ts new file mode 100644 index 0000000..7f62bd3 --- /dev/null +++ b/src/badges.ts @@ -0,0 +1,63 @@ +import { App, TAbstractFile, TFolder } from 'obsidian'; +import * as path from 'path'; +import { detectLink } from './detect'; + +type FileItem = { el: HTMLElement; selfEl?: HTMLElement }; +type ExplorerView = { fileItems?: Record }; + +export class BadgeRenderer { + private timer: number | null = null; + + constructor(private app: App, private vaultRoot: string) {} + + scheduleRefresh(delay = 250): void { + if (this.timer != null) window.clearTimeout(this.timer); + this.timer = window.setTimeout(() => this.refresh(), delay); + } + + refresh(): void { + for (const leaf of this.app.workspace.getLeavesOfType('file-explorer')) { + const view = leaf.view as unknown as ExplorerView; + const items = view.fileItems; + if (!items) continue; + for (const [vaultPath, item] of Object.entries(items)) { + const target = item.selfEl ?? item.el; + const af = this.app.vault.getAbstractFileByPath(vaultPath); + if (!(af instanceof TFolder)) { + this.clear(target); + continue; + } + const absPath = path.join(this.vaultRoot, af.path); + const state = detectLink(absPath); + this.apply(target, state.kind, state.kind === 'active' ? state.type : undefined); + } + } + } + + clearAll(): void { + for (const leaf of this.app.workspace.getLeavesOfType('file-explorer')) { + const view = leaf.view as unknown as ExplorerView; + const items = view.fileItems; + if (!items) continue; + for (const item of Object.values(items)) this.clear(item.selfEl ?? item.el); + } + } + + private apply(el: HTMLElement, kind: 'none' | 'active' | 'broken', type?: 'junction' | 'symlink'): void { + el.removeClasses(['sm-link-active', 'sm-link-broken', 'sm-link-junction', 'sm-link-symlink']); + if (kind === 'active') { + el.addClass('sm-link-active'); + if (type) el.addClass(`sm-link-${type}`); + } else if (kind === 'broken') { + el.addClass('sm-link-broken'); + } + } + + private clear(el: HTMLElement): void { + el.removeClasses(['sm-link-active', 'sm-link-broken', 'sm-link-junction', 'sm-link-symlink']); + } + + notify(_file: TAbstractFile): void { + this.scheduleRefresh(); + } +} diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..0eb3ac1 --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,85 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { LinkState, LinkType } from './types'; + +export function detectLink(absPath: string): LinkState { + let lst: fs.Stats; + try { + lst = fs.lstatSync(absPath); + } catch { + return { kind: 'none' }; + } + + // POSIX symlink + if (lst.isSymbolicLink()) { + return resolveLink(absPath, classifyLinkType(absPath)); + } + + // Windows Junction — shows as directory, but realpath differs from absPath + if (process.platform === 'win32' && lst.isDirectory()) { + try { + const real = fs.realpathSync(absPath); + if (real.toLowerCase() !== path.resolve(absPath).toLowerCase()) { + // realpath differs → this is a junction + return resolveLink(absPath, 'junction'); + } + } catch { + return { kind: 'broken', type: 'junction', target: absPath }; + } + } + + return { kind: 'none' }; +} + +function resolveLink(absPath: string, type: LinkType): LinkState { + let target = ''; + try { + target = fs.readlinkSync(absPath); + } catch { + // junction — readlink may fail on Windows, fallback to realpath + } + + if (target && !path.isAbsolute(target)) { + target = path.resolve(path.dirname(absPath), target); + } + + let resolved = ''; + try { + resolved = fs.realpathSync(absPath); + } catch { + return { kind: 'broken', type, target: target || absPath }; + } + + if (!target) target = resolved; + + let targetExists = false; + try { + targetExists = fs.statSync(resolved).isDirectory(); + } catch { + targetExists = false; + } + + return targetExists + ? { kind: 'active', type, target } + : { kind: 'broken', type, target }; +} + +function classifyLinkType(absPath: string): LinkType { + if (process.platform !== 'win32') return 'symlink'; + try { + const link = fs.readlinkSync(absPath); + if (!path.isAbsolute(link)) return 'symlink'; + const linkDrive = path.parse(link).root.toLowerCase(); + const hostDrive = path.parse(path.resolve(absPath)).root.toLowerCase(); + return linkDrive === hostDrive ? 'junction' : 'symlink'; + } catch { + return 'symlink'; + } +} + +export function suggestLinkType(linkPath: string, targetPath: string): LinkType { + if (process.platform !== 'win32') return 'symlink'; + const a = path.parse(path.resolve(linkPath)).root.toLowerCase(); + const b = path.parse(path.resolve(targetPath)).root.toLowerCase(); + return a && b && a === b ? 'junction' : 'symlink'; +} \ No newline at end of file diff --git a/src/dialog.ts b/src/dialog.ts new file mode 100644 index 0000000..6051aa9 --- /dev/null +++ b/src/dialog.ts @@ -0,0 +1,47 @@ +/** + * Open the native OS folder picker via Electron's remote dialog. + * Returns the chosen path, or null if the user cancelled. + * + * Electron is provided by Obsidian desktop. Mobile builds will not reach + * this code because the plugin is marked isDesktopOnly. + */ +export async function browseFolder(defaultPath?: string): Promise { + // Electron exposes `dialog` via the @electron/remote module loaded by Obsidian. + // We reach for it through the global require so esbuild leaves it alone. + type DialogModule = { + showOpenDialog: (opts: { + title?: string; + defaultPath?: string; + properties: string[]; + }) => Promise<{ canceled: boolean; filePaths: string[] }>; + }; + + const req = (window as unknown as { require?: (m: string) => unknown }).require; + if (typeof req !== 'function') return null; + + let dialog: DialogModule | null = null; + try { + const remote = req('@electron/remote') as { dialog?: DialogModule }; + if (remote?.dialog) dialog = remote.dialog; + } catch { + // fall through + } + if (!dialog) { + try { + const electron = req('electron') as { remote?: { dialog?: DialogModule } }; + if (electron?.remote?.dialog) dialog = electron.remote.dialog; + } catch { + // fall through + } + } + if (!dialog) return null; + + const result = await dialog.showOpenDialog({ + title: 'Choose target folder', + defaultPath, + properties: ['openDirectory'], + }); + + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0] ?? null; +} diff --git a/src/main.ts b/src/main.ts index 6fe0c83..b6aeabb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,83 @@ -import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings"; +import { FileSystemAdapter, Menu, Notice, Plugin, TAbstractFile, TFolder } from 'obsidian'; +import { BadgeRenderer } from './badges'; +import { SymlinkModal } from './modal'; +import { DEFAULT_SETTINGS, SymlinkManagerSettings, SymlinkManagerSettingTab } from './settings'; -// Remember to rename these classes and interfaces! +export default class SymlinkManagerPlugin extends Plugin { + settings!: SymlinkManagerSettings; + private badges: BadgeRenderer | null = null; + private vaultRoot = ''; -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; - - async onload() { + async onload(): Promise { await this.loadSettings(); - // This creates an icon in the left ribbon. - this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + new Notice('Symlink Manager: this plugin only runs on Obsidian desktop.'); + return; + } + this.vaultRoot = adapter.getBasePath(); - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status bar text'); + this.badges = new BadgeRenderer(this.app, this.vaultRoot); - // This adds a simple command that can be triggered anywhere - this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', - callback: () => { - new SampleModal(this.app).open(); - } - }); - // This adds an editor command that can perform some operation on the current editor instance this.addCommand({ - id: 'replace-selected', - name: 'Replace selected content', - editorCallback: (editor: Editor, view: MarkdownView) => { - editor.replaceSelection('Sample editor command'); - } - }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-modal-complex', - name: 'Open modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - - // This command will only show up in Command Palette when the check function returns true - return true; - } - return false; - } - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - new Notice("Click"); + id: 'manage-active-folder', + name: 'Manage symlink for active folder', + callback: () => this.openModalForVaultPath(''), }); - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); - - } - - onunload() { + this.registerEvent( + this.app.workspace.on('file-menu', (menu: Menu, file: TAbstractFile) => { + if (!(file instanceof TFolder)) return; + menu.addItem((item) => + item + .setTitle('Symlink: manage this folder') + .setIcon('link') + .onClick(() => this.openModalForVaultPath(file.path)) + ); + }) + ); + + this.app.workspace.onLayoutReady(() => this.applyBadgeSetting()); + + // Folder rename/create/delete can change link state visible in the tree. + this.registerEvent(this.app.vault.on('create', (f) => this.badges?.notify(f))); + this.registerEvent(this.app.vault.on('delete', (f) => this.badges?.notify(f))); + this.registerEvent(this.app.vault.on('rename', (f) => this.badges?.notify(f))); + + this.addSettingTab(new SymlinkManagerSettingTab(this.app, this)); } - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + onunload(): void { + this.badges?.clearAll(); + this.badges = null; } - async saveSettings() { - await this.saveData(this.settings); + openModalForVaultPath(initialVaultPath: string): void { + if (!this.vaultRoot) { + new Notice('Symlink Manager: vault root unavailable.'); + return; + } + new SymlinkModal(this.app, { + vaultRoot: this.vaultRoot, + initialVaultPath, + confirmDisconnect: this.settings.confirmDisconnect, + onChange: () => this.badges?.scheduleRefresh(50), + }).open(); } -} -class SampleModal extends Modal { - constructor(app: App) { - super(app); + applyBadgeSetting(): void { + if (!this.badges) return; + if (this.settings.showBadges) this.badges.scheduleRefresh(0); + else this.badges.clearAll(); } - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); + async loadSettings(): Promise { + const data = (await this.loadData()) as Partial | null; + this.settings = Object.assign({}, DEFAULT_SETTINGS, data ?? {}); } - onClose() { - const {contentEl} = this; - contentEl.empty(); + async saveSettings(): Promise { + await this.saveData(this.settings); } } diff --git a/src/modal.ts b/src/modal.ts new file mode 100644 index 0000000..ac8f396 --- /dev/null +++ b/src/modal.ts @@ -0,0 +1,380 @@ +import { + App, + ButtonComponent, + DropdownComponent, + Modal, + Notice, + Setting, + TextComponent, + normalizePath, +} from 'obsidian'; +import * as path from 'path'; +import { detectLink, suggestLinkType } from './detect'; +import { copyAndDisconnect, createLink, removeLink, repointLink } from './ops'; +import { browseFolder } from './dialog'; +import { applyResponsivePath } from './truncate'; +import type { LinkInfo, LinkType } from './types'; + +export interface SymlinkModalOptions { + vaultRoot: string; + initialVaultPath: string; + confirmDisconnect: boolean; + onChange?: () => void; +} + +export class SymlinkModal extends Modal { + private opts: SymlinkModalOptions; + private info!: LinkInfo; + private vaultPathInput!: TextComponent; + private statusEl!: HTMLElement; + private bodyEl!: HTMLElement; + private disposers: Array<() => void> = []; + + constructor(app: App, opts: SymlinkModalOptions) { + super(app); + this.opts = opts; + } + + onOpen(): void { + this.modalEl.addClass('symlink-manager-modal'); + this.titleEl.setText('Symlink Manager'); + this.contentEl.empty(); + + const headerWrap = this.contentEl.createDiv({ cls: 'sm-section' }); + new Setting(headerWrap) + .setName('Folder (vault)') + .setDesc('Editable — relative to vault root') + .addText((t) => { + this.vaultPathInput = t; + t.setPlaceholder('_project/MyProject'); + t.setValue(this.opts.initialVaultPath); + t.onChange(() => this.refresh()); + }); + + this.statusEl = this.contentEl.createDiv({ cls: 'sm-status' }); + this.bodyEl = this.contentEl.createDiv({ cls: 'sm-body' }); + + this.refresh(); + } + + onClose(): void { + this.disposeAll(); + this.contentEl.empty(); + } + + private disposeAll(): void { + for (const d of this.disposers) { + try { d(); } catch { /* ignore */ } + } + this.disposers = []; + } + + private refresh(): void { + const raw = this.vaultPathInput.getValue().trim(); + const vaultPath = normalizePath(raw || this.opts.initialVaultPath || '/'); + const absPath = path.join(this.opts.vaultRoot, vaultPath); + this.info = { absPath, vaultPath, state: detectLink(absPath) }; + + this.renderStatus(); + this.renderBody(); + } + + private renderStatus(): void { + // New status DOM removes any prior ResizeObservers it may have hooked up. + this.disposeAll(); + this.statusEl.empty(); + + const s = this.info.state; + + const line = this.statusEl.createDiv({ cls: 'sm-status-line' }); + const dotKind = s.kind === 'active' ? s.type : s.kind === 'broken' ? 'broken' : 'none'; + line.createSpan({ cls: `sm-dot sm-dot-${dotKind}` }); + + const label = line.createSpan({ cls: 'sm-status-label' }); + label.setText(statusLabel(s)); + + if (s.kind === 'none') return; + + const targetRow = this.statusEl.createDiv({ cls: 'sm-target-row' }); + targetRow.createSpan({ cls: 'sm-target-arrow', text: '→' }); + const pathEl = targetRow.createSpan({ cls: 'sm-target-path' }); + this.disposers.push(applyResponsivePath(pathEl, s.target)); + + if (s.kind === 'broken') { + this.statusEl.createDiv({ + cls: 'sm-warn', + text: '⚠️ Target not found / drive offline.', + }); + } + } + + private renderBody(): void { + this.bodyEl.empty(); + switch (this.info.state.kind) { + case 'none': + this.renderCreate(); + return; + case 'active': + this.renderActive(); + return; + case 'broken': + this.renderBroken(); + return; + } + } + + // ── State A: create ──────────────────────────────────────────────── + private renderCreate(): void { + let targetInput!: TextComponent; + let typeDropdown!: DropdownComponent; + let typeRow!: Setting; + let chosenType: LinkType = 'junction'; + + const setType = (t: LinkType): void => { + chosenType = t; + typeDropdown.setValue(t); + typeRow.setDesc(typeHint(t)); + }; + + new Setting(this.bodyEl) + .setName('External path') + .setDesc('Type a path or use Browse') + .addText((t) => { + targetInput = t; + t.setPlaceholder('D:\\Projects\\MyProject'); + t.onChange((v) => { + if (!v.trim()) return; + setType(suggestLinkType(this.info.absPath, v.trim())); + }); + }) + .addButton((b) => + b.setButtonText('Browse').onClick(async () => { + const picked = await browseFolder(); + if (picked) { + targetInput.setValue(picked); + setType(suggestLinkType(this.info.absPath, picked)); + } + }) + ); + + typeRow = new Setting(this.bodyEl) + .setName('Link type') + .setDesc(typeHint(chosenType)) + .addDropdown((d) => { + typeDropdown = d; + d.addOption('junction', 'Junction (Windows · same drive · no admin)'); + d.addOption('symlink', 'Symlink (cross-drive / POSIX · admin on Win)'); + d.setValue(chosenType); + d.onChange((v) => { + chosenType = v as LinkType; + typeRow.setDesc(typeHint(chosenType)); + }); + }); + + const actions = this.bodyEl.createDiv({ cls: 'sm-actions' }); + new ButtonComponent(actions).setButtonText('Cancel').onClick(() => this.close()); + new ButtonComponent(actions) + .setButtonText('Create link') + .setCta() + .onClick(() => { + const target = targetInput.getValue().trim(); + if (!target) { + new Notice('Pick a target folder first.'); + return; + } + void this.guard(async () => { + await createLink(this.info.absPath, target, chosenType); + new Notice(`Link created → ${target}`); + }); + }); + } + + // ── State B: active ──────────────────────────────────────────────── + private renderActive(): void { + const s = this.info.state; + if (s.kind !== 'active') return; + + new Setting(this.bodyEl) + .setName('Target path') + .setDesc(s.target) + .addButton((b) => + b.setButtonText('Edit target').onClick(() => { + this.bodyEl.empty(); + this.renderEditTarget(s.target, s.type); + }) + ); + + this.bodyEl.createEl('h4', { text: 'Actions', cls: 'sm-actions-title' }); + + const disconnectCard = this.bodyEl.createDiv({ cls: 'sm-card' }); + disconnectCard.createDiv({ cls: 'sm-card-title', text: '🔌 Disconnect' }); + disconnectCard.createDiv({ + cls: 'sm-card-desc', + text: 'Remove the link only — files at the target are untouched.', + }); + new ButtonComponent(disconnectCard) + .setButtonText('Disconnect') + .setWarning() + .onClick(() => { + if (!this.confirm('Remove the link? Files at the target stay where they are.')) return; + void this.guard(async () => { + await removeLink(this.info.absPath); + new Notice('Link removed.'); + }); + }); + + const copyCard = this.bodyEl.createDiv({ cls: 'sm-card' }); + copyCard.createDiv({ cls: 'sm-card-title', text: '📦 Disconnect + Copy' }); + copyCard.createDiv({ + cls: 'sm-card-desc', + text: 'Copy contents into the vault first, then remove the link. Large folders may take a while.', + }); + new ButtonComponent(copyCard) + .setButtonText('Copy & disconnect') + .onClick(() => { + if (!this.confirm('Copy contents into the vault and then disconnect?')) return; + void this.guard(async () => { + new Notice('Copying — this may take a while…'); + await copyAndDisconnect(this.info.absPath); + new Notice('Copied and detached.'); + }); + }); + + const close = this.bodyEl.createDiv({ cls: 'sm-actions' }); + new ButtonComponent(close).setButtonText('Close').onClick(() => this.close()); + } + + private renderEditTarget(current: string, initialType: LinkType): void { + let targetInput!: TextComponent; + let chosenType: LinkType = initialType; + + new Setting(this.bodyEl) + .setName('New target path') + .addText((t) => { + targetInput = t; + t.setValue(current); + t.onChange((v) => { + if (!v.trim()) return; + chosenType = suggestLinkType(this.info.absPath, v.trim()); + }); + }) + .addButton((b) => + b.setButtonText('Browse').onClick(async () => { + const picked = await browseFolder(targetInput.getValue() || undefined); + if (picked) { + targetInput.setValue(picked); + chosenType = suggestLinkType(this.info.absPath, picked); + } + }) + ); + + const actions = this.bodyEl.createDiv({ cls: 'sm-actions' }); + new ButtonComponent(actions).setButtonText('Cancel').onClick(() => this.refresh()); + new ButtonComponent(actions) + .setButtonText('Apply') + .setCta() + .onClick(() => { + const v = targetInput.getValue().trim(); + if (!v) { + new Notice('Target path is empty.'); + return; + } + void this.guard(async () => { + await repointLink(this.info.absPath, v, chosenType); + new Notice('Target updated.'); + }); + }); + } + + // ── State C: broken ──────────────────────────────────────────────── + private renderBroken(): void { + this.bodyEl.createEl('h4', { text: 'Actions', cls: 'sm-actions-title' }); + + const removeCard = this.bodyEl.createDiv({ cls: 'sm-card' }); + removeCard.createDiv({ cls: 'sm-card-title', text: '🔌 Remove broken link' }); + removeCard.createDiv({ cls: 'sm-card-desc', text: 'Delete the dangling pointer. No data is lost.' }); + new ButtonComponent(removeCard) + .setButtonText('Remove') + .setWarning() + .onClick(() => { + if (!this.confirm('Remove the broken link pointer?')) return; + void this.guard(async () => { + await removeLink(this.info.absPath); + new Notice('Broken link removed.'); + }); + }); + + const repointCard = this.bodyEl.createDiv({ cls: 'sm-card' }); + repointCard.createDiv({ cls: 'sm-card-title', text: '🔁 Repoint to new path' }); + repointCard.createDiv({ cls: 'sm-card-desc', text: 'Pick a new target and re-create the link in place.' }); + + let targetInput!: TextComponent; + let chosenType: LinkType = 'junction'; + const startTarget = this.info.state.kind === 'broken' ? this.info.state.target : ''; + + new Setting(repointCard) + .setName('New target') + .addText((t) => { + targetInput = t; + t.setValue(startTarget); + t.onChange((v) => { + if (!v.trim()) return; + chosenType = suggestLinkType(this.info.absPath, v.trim()); + }); + }) + .addButton((b) => + b.setButtonText('Browse').onClick(async () => { + const picked = await browseFolder(targetInput.getValue() || undefined); + if (picked) { + targetInput.setValue(picked); + chosenType = suggestLinkType(this.info.absPath, picked); + } + }) + ); + + new ButtonComponent(repointCard) + .setButtonText('Repoint') + .setCta() + .onClick(() => { + const v = targetInput.getValue().trim(); + if (!v) { + new Notice('Pick a target first.'); + return; + } + void this.guard(async () => { + await repointLink(this.info.absPath, v, chosenType); + new Notice('Repointed.'); + }); + }); + + const close = this.bodyEl.createDiv({ cls: 'sm-actions' }); + new ButtonComponent(close).setButtonText('Close').onClick(() => this.close()); + } + + private confirm(message: string): boolean { + if (!this.opts.confirmDisconnect) return true; + return window.confirm(message); + } + + private async guard(fn: () => Promise): Promise { + try { + await fn(); + this.opts.onChange?.(); + this.refresh(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Symlink Manager: ${msg}`, 8000); + } + } +} + +function typeHint(type: LinkType): string { + if (type === 'junction') return 'Same drive — junction, no admin required.'; + return 'Cross-drive or POSIX — symlink (admin required on Windows).'; +} + +function statusLabel(s: LinkInfo['state']): string { + if (s.kind === 'none') return 'No symlink detected'; + if (s.kind === 'broken') return 'Broken link detected'; + return s.type === 'junction' ? 'Junction active' : 'Symlink active'; +} diff --git a/src/ops.ts b/src/ops.ts new file mode 100644 index 0000000..aae5289 --- /dev/null +++ b/src/ops.ts @@ -0,0 +1,136 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { LinkType } from './types'; + +const execAsync = promisify(exec); + +/** + * Create a junction (Windows, same-drive, no admin) or a symbolic link. + * The link path's parent must exist; the link path itself must NOT exist. + */ +export async function createLink(linkPath: string, targetPath: string, type: LinkType): Promise { + await assertPathFree(linkPath); + await assertTargetExists(targetPath); + await fs.promises.mkdir(path.dirname(linkPath), { recursive: true }); + + if (process.platform === 'win32') { + // mklink is a cmd.exe builtin, not a standalone exe — must run via cmd /c. + const flag = type === 'junction' ? '/J' : '/D'; + const cmd = `cmd /c mklink ${flag} ${quote(linkPath)} ${quote(targetPath)}`; + try { + await execAsync(cmd, { windowsHide: true }); + } catch (err: unknown) { + throw normalizeMklinkError(err, type); + } + return; + } + + // POSIX: dir/file symlink — Node infers type on Linux/macOS. + await fs.promises.symlink(targetPath, linkPath, 'dir'); +} + +/** + * Remove a link pointer only. The target stays untouched. + * On Windows a directory junction must be removed with rmdir, not unlink. + */ +export async function removeLink(linkPath: string): Promise { + const lst = await fs.promises.lstat(linkPath); + if (!lst.isSymbolicLink()) { + throw new Error(`Not a symlink/junction: ${linkPath}`); + } + + if (process.platform === 'win32') { + try { + await fs.promises.unlink(linkPath); + } catch { + // Junctions appear as directories to rmdir. + await fs.promises.rmdir(linkPath); + } + return; + } + + await fs.promises.unlink(linkPath); +} + +/** + * Copy the link's contents into the link's location, then replace the link + * with the copied folder. Effectively: snapshot then detach. + */ +export async function copyAndDisconnect(linkPath: string): Promise { + const lst = await fs.promises.lstat(linkPath); + if (!lst.isSymbolicLink()) { + throw new Error(`Not a symlink/junction: ${linkPath}`); + } + + const resolved = await fs.promises.realpath(linkPath); + const tmp = path.join(path.dirname(linkPath), `.${path.basename(linkPath)}.copying-${Date.now()}`); + + await fs.promises.cp(resolved, tmp, { recursive: true, dereference: true }); + + try { + await removeLink(linkPath); + } catch (err) { + await safeRemove(tmp); + throw err; + } + + await fs.promises.rename(tmp, linkPath); +} + +/** Atomically repoint: remove old link and create a new one at the same path. */ +export async function repointLink(linkPath: string, newTarget: string, type: LinkType): Promise { + await assertTargetExists(newTarget); + try { + await removeLink(linkPath); + } catch { + // If the existing entry was already missing/broken, ignore — we'll try to create. + } + await createLink(linkPath, newTarget, type); +} + +function quote(p: string): string { + return `"${p.replace(/"/g, '\\"')}"`; +} + +async function assertPathFree(p: string): Promise { + try { + await fs.promises.lstat(p); + } catch { + return; + } + throw new Error(`Path already exists: ${p}`); +} + +async function assertTargetExists(p: string): Promise { + let st: fs.Stats; + try { + st = await fs.promises.stat(p); + } catch { + throw new Error(`Target does not exist: ${p}`); + } + if (!st.isDirectory()) { + throw new Error(`Target is not a folder: ${p}`); + } +} + +async function safeRemove(p: string): Promise { + try { + await fs.promises.rm(p, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +function normalizeMklinkError(err: unknown, type: LinkType): Error { + const msg = err instanceof Error ? err.message : String(err); + if (type === 'symlink' && /privilege|elevation|denied/i.test(msg)) { + return new Error( + 'Creating a symbolic link requires admin privileges on Windows. ' + + 'Run Obsidian as administrator, or enable Developer Mode, ' + + 'or pick a target on the same drive to use a junction instead.' + ); + } + return new Error(`mklink failed: ${msg}`); +} diff --git a/src/settings.ts b/src/settings.ts index 352121e..1fbfd83 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,36 +1,51 @@ -import {App, PluginSettingTab, Setting} from "obsidian"; -import MyPlugin from "./main"; +import { App, PluginSettingTab, Setting } from 'obsidian'; +import type SymlinkManagerPlugin from './main'; -export interface MyPluginSettings { - mySetting: string; +export interface SymlinkManagerSettings { + /** Show 🟢🟠🔴 badges in the file explorer. */ + showBadges: boolean; + /** Confirm before removing a link. */ + confirmDisconnect: boolean; } -export const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} - -export class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; +export const DEFAULT_SETTINGS: SymlinkManagerSettings = { + showBadges: true, + confirmDisconnect: true, +}; - constructor(app: App, plugin: MyPlugin) { +export class SymlinkManagerSettingTab extends PluginSettingTab { + constructor(app: App, private plugin: SymlinkManagerPlugin) { super(app, plugin); - this.plugin = plugin; } display(): void { - const {containerEl} = this; - + const { containerEl } = this; containerEl.empty(); new Setting(containerEl) - .setName('Settings #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; + .setName('Show status badges in file explorer') + .setDesc('Color folders that contain a junction or symlink: green = junction, orange = symlink, red = broken.') + .addToggle((t) => + t.setValue(this.plugin.settings.showBadges).onChange(async (v) => { + this.plugin.settings.showBadges = v; await this.plugin.saveSettings(); - })); + this.plugin.applyBadgeSetting(); + }) + ); + + new Setting(containerEl) + .setName('Confirm before disconnect') + .setDesc('Ask for confirmation before removing a link or running Disconnect + Copy.') + .addToggle((t) => + t.setValue(this.plugin.settings.confirmDisconnect).onChange(async (v) => { + this.plugin.settings.confirmDisconnect = v; + await this.plugin.saveSettings(); + }) + ); + + const tip = containerEl.createDiv({ cls: 'setting-item-description' }); + tip.createEl('p', { + text: 'Tip: for Obsidian to index files inside a linked folder, enable "Detect all file extensions" and reload the vault if needed.', + }); } } diff --git a/src/truncate.ts b/src/truncate.ts new file mode 100644 index 0000000..8a6fe92 --- /dev/null +++ b/src/truncate.ts @@ -0,0 +1,65 @@ +/** + * Middle-ellipsis a path so it fits the element's current width. + * Re-runs whenever the element resizes. + * + * wide → D:\Projects\Foo\Bar\baz + * medium → D:\Projects\…\baz + * tiny → D:\… + * + * Returns a disposer so callers can disconnect the observer on teardown. + */ +export function applyResponsivePath(el: HTMLElement, full: string): () => void { + el.dataset.fullPath = full; + + const compute = (): void => { + const width = el.clientWidth; + if (width <= 0) return; + + const charPx = estimateCharPx(el); + const max = Math.max(3, Math.floor(width / charPx)); + + el.textContent = squeeze(full, max); + el.title = full; + }; + + compute(); + + const ro = new ResizeObserver(compute); + ro.observe(el); + return () => ro.disconnect(); +} + +function estimateCharPx(el: HTMLElement): number { + const cs = getComputedStyle(el); + const fontSize = parseFloat(cs.fontSize) || 13; + // Monospace fonts run ~0.6em per char; proportional UI fonts ~0.55em. + const factor = /mono/i.test(cs.fontFamily) ? 0.6 : 0.55; + return Math.max(5, fontSize * factor); +} + +function squeeze(full: string, max: number): string { + if (full.length <= max) return full; + + const sepRx = /[\\/]/; + const segments = full.split(sepRx); + const sep = full.match(sepRx)?.[0] ?? '/'; + + // Very narrow: just the root (drive letter or first segment) + ellipsis. + if (max < 12) { + const head = segments[0] ?? full.slice(0, 3); + const candidate = `${head}${sep}…`; + return candidate.length <= max ? candidate : `…${full.slice(-Math.max(1, max - 1))}`; + } + + // Medium: keep first segment and last segment, ellipsis in the middle. + if (segments.length >= 3) { + const first = segments[0] ?? ''; + const last = segments[segments.length - 1] ?? ''; + const candidate = `${first}${sep}…${sep}${last}`; + if (candidate.length <= max) return candidate; + } + + // Fallback: simple character-level middle ellipsis. + const half = Math.max(1, Math.floor((max - 1) / 2)); + return `${full.slice(0, half)}…${full.slice(full.length - (max - half - 1))}`; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d707dfa --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +export type LinkType = 'junction' | 'symlink'; + +export type LinkState = + | { kind: 'none' } + | { kind: 'active'; type: LinkType; target: string } + | { kind: 'broken'; type: LinkType; target: string }; + +export interface LinkInfo { + /** Absolute filesystem path to the folder inside the vault. */ + absPath: string; + /** Path relative to vault root, using forward slashes. */ + vaultPath: string; + /** Current detected state. */ + state: LinkState; +} diff --git a/styles.css b/styles.css index 71cc60f..9c19d4f 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,161 @@ -/* +/* Symlink Manager — modal layout */ -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.symlink-manager-modal .sm-section { + margin-bottom: 0.75rem; +} -If your plugin does not need CSS, delete this file. +.symlink-manager-modal .sm-status { + margin: 0.5rem 0 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + background: var(--background-secondary); + min-width: 0; +} -*/ +.symlink-manager-modal .sm-status-line { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} + +.symlink-manager-modal .sm-status-label { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.symlink-manager-modal .sm-target-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.25rem; + min-width: 0; +} + +.symlink-manager-modal .sm-target-arrow { + flex: 0 0 auto; + color: var(--text-muted); +} + +.symlink-manager-modal .sm-target-path { + flex: 1 1 auto; + min-width: 0; + font-family: var(--font-monospace); + font-size: var(--font-ui-smaller); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; +} + +.symlink-manager-modal .sm-warn { + color: var(--color-orange, #d08a3a); + font-size: var(--font-ui-smaller); + margin-top: 0.25rem; +} + +/* Status dot */ + +.symlink-manager-modal .sm-dot { + flex: 0 0 auto; + width: 0.65em; + height: 0.65em; + border-radius: 50%; + display: inline-block; + background: var(--background-modifier-border); +} + +.symlink-manager-modal .sm-dot-junction { background: #3aa757 !important; } +.symlink-manager-modal .sm-dot-symlink { background: #d08a3a !important; } +.symlink-manager-modal .sm-dot-broken { background: #d04a4a !important; } +.symlink-manager-modal .sm-dot-none { background: var(--background-modifier-border) !important; } + +/* Action cards */ + +.symlink-manager-modal .sm-actions-title { + margin: 0.75rem 0 0.5rem; + font-size: var(--font-ui-smaller); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.symlink-manager-modal .sm-card { + padding: 0.75rem; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + margin-bottom: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.symlink-manager-modal .sm-card-title { + font-weight: 600; +} + +.symlink-manager-modal .sm-card-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.symlink-manager-modal .sm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; +} + +/* ── File explorer badges ────────────────────────────────────────────────── + * Badge dot sits on the RIGHT side of the folder name. + * Uses ::after on the title element itself so it never gets clipped. + * Hard-coded hex colours + !important so no theme can override them. + * ------------------------------------------------------------------------- */ + +.nav-folder-title.sm-link-active, +.nav-folder-title.sm-link-broken { + display: flex !important; + align-items: center !important; + min-width: 0 !important; +} + +/* Let the folder name truncate naturally */ +.nav-folder-title.sm-link-active .nav-folder-title-content, +.nav-folder-title.sm-link-broken .nav-folder-title-content { + flex: 1 1 auto !important; + min-width: 0 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +/* Base dot — RIGHT side via margin-left: auto pushes it to the end */ +.nav-folder-title.sm-link-active::after, +.nav-folder-title.sm-link-broken::after { + content: '' !important; + display: inline-block !important; + flex: 0 0 auto !important; + width: 0.55em !important; + height: 0.55em !important; + border-radius: 50% !important; + margin-left: 0.5em !important; + margin-right: 0.3em !important; + align-self: center !important; + /* fallback colour — overridden by type classes below */ + background: #888 !important; +} + +/* Colour overrides — hard-coded hex, !important, no CSS variable so themes can't win */ +.nav-folder-title.sm-link-junction::after { + background: #3aa757 !important; /* green — junction, same drive */ +} + +.nav-folder-title.sm-link-symlink::after { + background: #d08a3a !important; /* orange — symlink, cross drive */ +} + +.nav-folder-title.sm-link-broken::after { + background: #d04a4a !important; /* red — broken / drive offline */ +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 222535d..cee665f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "baseUrl": "src", + "ignoreDeprecations": "5.0", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", @@ -9,7 +9,7 @@ "noImplicitAny": true, "noImplicitThis": true, "noImplicitReturns": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, @@ -17,6 +17,7 @@ "strictBindCallApply": true, "allowSyntheticDefaultImports": true, "useUnknownInCatchVariables": true, + "types": ["node"], "lib": [ "DOM", "ES5", @@ -27,4 +28,4 @@ "include": [ "src/**/*.ts" ] -} +} \ No newline at end of file