Skip to content

Commit

Permalink
Support 0.16.3 breadcrumbs and allow hiding
Browse files Browse the repository at this point in the history
If you have Obsidian 0.16.3 or above, you can now click its
tab title bar "breadcrumbs" to access Quick Explorer (or double-
click to open the folder in the built-in File Explorer).

There is now also a [Style Settings](https://github.com/mgmeyers/obsidian-style-settings)
option you can use to hide the Quick Explorer, such that it does
not show in the title bar or status bar.  If hidden, Quick Explorer
keyboard commands will use the most recently active tab's header
breadcrumbs to display its menus (assuming you're on 0.16.3 and
the tab title bar is visible).
  • Loading branch information
pjeby committed Sep 25, 2022
1 parent d60db4c commit 17241f3
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 45 deletions.
2 changes: 2 additions & 0 deletions README.md
@@ -1,5 +1,7 @@
## Quick Explorer for Obsidian

> New in 0.2.0: If you're using Obsidian 0.16.3 and have tab titlebars turned on, Quick Explorer can use them in addition to (or in place of) the main title bar or status bar. If you want to hide QE's default breadcrumbs, you can turn them off using the [Style Settings](https://github.com/mgmeyers/obsidian-style-settings) plugin.
[Obsidian](https://obsidian.md)'s in-app file explorer is pretty flexible, but it's almost 100% mouse-driven and not at all keyboard-friendly. Worse, if you have a lot of folders with lots of files in them, you can spend a lot of time expanding and collapsing folders, and scrolling around to find what you're looking for. This can be especially annoying when all you want is to do something with the "current" folder, or a parent of it... *without* needing to open a sidebar and close it again afterwards. (And last, but not least, trying to rapidly preview the contents of a lot of notes with the mouse is a giant PITA.)

Enter Quick Explorer. It's menu-based and **keyboard-friendly**, stays out of your way when you aren't using it, and makes it super-easy to navigate from either the vault root or current folder, without needing to scroll through or collapse a zillion other folders to find what you're looking for. You can even **search by name within a folder**, just by typing.
Expand Down
4 changes: 3 additions & 1 deletion manifest.json
@@ -1,7 +1,9 @@
{
"id": "quick-explorer",
"name": "Quick Explorer",
"version": "0.1.41",
"author": "PJ Eby",
"authorUrl": "https://github.com/pjeby",
"version": "0.2.0",
"description": "Perform file explorer operations (and see your current file path) from the title bar, using the mouse or keyboard",
"minAppVersion": "0.15.9",
"isDesktopOnly": true
Expand Down
139 changes: 123 additions & 16 deletions src/Explorer.tsx
@@ -1,8 +1,8 @@
import { App, FileView, Notice, Plugin, requireApiVersion, TAbstractFile, TFile, TFolder } from "obsidian";
import { App, FileView, requireApiVersion, TAbstractFile, TFile, TFolder, WorkspaceLeaf } from "obsidian";
import { list, el, mount, unmount } from "redom";
import { ContextMenu } from "./ContextMenu";
import { FolderMenu } from "./FolderMenu";
import { PerWindowComponent, statusBarItem } from "@ophidian/core";
import { onElement, PerWindowComponent, statusBarItem } from "@ophidian/core";

export const hoverSource = "quick-explorer:folder-menu";

Expand Down Expand Up @@ -39,12 +39,16 @@ class Explorable {
export class Explorer extends PerWindowComponent {
lastFile: TAbstractFile = null;
lastPath: string = null;
lastMenu: FolderMenu;

el: HTMLElement = <div id="quick-explorer" />;
list = list(this.el, Explorable);
isOpen = 0
app = app;

onload() {
// Try to close any open menu before unloading
this.register(() => this.lastMenu?.hide());
if (requireApiVersion("0.15.6")) {
const originalTitleEl = this.win.document.body.find(".titlebar .titlebar-inner .titlebar-text");
const titleEl = originalTitleEl?.cloneNode(true) as HTMLElement;
Expand All @@ -58,6 +62,29 @@ export class Explorer extends PerWindowComponent {

if (requireApiVersion("0.16.0")) this.win.document.body.addClass("obsidian-themepocalypse");

if (requireApiVersion("0.16.3")) {
const selector = ".view-header .view-header-breadcrumb, .view-header .view-header-title-parent";
this.register(onElement(this.win.document.body, "click", selector, (e, target) => {
// Ignore if separator, or if a menu is already open for the item (.is-exploring)
// (This allows double-click to open the file explorer)
if ((e.target as HTMLElement).matches(".view-header-breadcrumb-separator, .is-exploring")) return;
tabCrumb(target)?.open(e);
e.stopPropagation();
return false;
}, {capture: true}));
this.register(onElement(
this.win.document.body, "contextmenu", ".view-header .view-header-breadcrumb", (e, target) => {
if ((e.target as HTMLElement).matches(".view-header-breadcrumb-separator")) return;
const folder = tabCrumb(target)?.file?.parent;
if (folder) {
new ContextMenu(this.app, folder).cascade(target, e);
e.stopImmediatePropagation();
return false;
}
}, {capture: true}
));
}

const buttonContainer = this.win.document.body.find(
"body:not(.is-hidden-frameless) .titlebar .titlebar-button-container.mod-left"
) || statusBarItem(this, this.win, "left-region");
Expand Down Expand Up @@ -97,15 +124,23 @@ export class Explorer extends PerWindowComponent {
if (file === this.lastFile) this.update();
}

visibleCrumb(opener: HTMLElement) {
let crumb = explorableCrumb(this, opener);
if (!opener.isShown()) {
const altOpener = app.workspace.getActiveViewOfType(FileView).containerEl.find(
".view-header .view-header-title-parent"
);
if (altOpener?.isShown()) {
const {file} = crumb;
crumb = tabCrumb(altOpener);
crumb = crumb.peers.find(c => c.file === file) || crumb;
}
}
return crumb;
}

folderMenu(opener: HTMLElement = this.el.firstElementChild as HTMLElement, event?: MouseEvent) {
const { filePath, parentPath } = opener.dataset
const selected = this.app.vault.getAbstractFileByPath(filePath);
const folder = this.app.vault.getAbstractFileByPath(parentPath) as TFolder;
this.isOpen++;
return new FolderMenu(this.app, folder, selected, opener).cascade(opener, event, () => {
this.isOpen--;
if (!this.isOpen && this.isCurrent()) this.update(this.app.workspace.getActiveFile());
});
return this.lastMenu = this.visibleCrumb(opener)?.open(event);
}

browseVault() {
Expand Down Expand Up @@ -154,13 +189,85 @@ export class Explorer extends PerWindowComponent {
if (file == this.lastFile && file.path == this.lastPath) return;
this.lastFile = file;
this.lastPath = file.path;
const parts = [];
while (file) {
parts.unshift({ file, path: file.path });
file = file.parent;
}
if (parts.length > 1) parts.shift();
const parts = hierarchy(file);
this.list.update(parts);
}

}

export class Breadcrumb {
constructor(
public peers: Breadcrumb[],
public el: HTMLElement,
public file: TAbstractFile,
public onOpen?: (crumb: Breadcrumb) => any,
public onClose?: (crumb: Breadcrumb) => any,
) {
peers.push(this);
}
next() {
const i = this.peers.indexOf(this);
if (i>-1) return this.peers[i+1];
}
prev() {
const i = this.peers.indexOf(this);
if (i>0) return this.peers[i-1];
}
open(e?: MouseEvent) {
const selected = this.file;
if (selected) {
this.onOpen?.(this)
const folder = this.file.parent || selected as TFolder;
return new FolderMenu(app, folder, selected, this).cascade(
this.el, e && e.isTrusted && e, () => this.onClose(this)
);
}
}
}

function tabCrumb(opener: HTMLElement) {
const crumbs: Breadcrumb[] = [];
const leafEl = opener.matchParent(".workspace-leaf");
let leaf: WorkspaceLeaf, crumb: Breadcrumb;
app.workspace.iterateAllLeaves(l => l.containerEl === leafEl && (leaf = l) && true);
const file = (leaf?.view as FileView)?.file;
const tree = hierarchy(file);
const parent = opener.matchParent(".view-header-title-parent");
crumb = new Breadcrumb(crumbs, parent as HTMLElement, tree.shift().file, onOpen, onClose);
for (const el of parent.findAll(".view-header-breadcrumb")) {
new Breadcrumb(crumbs, el, tree.shift().file, onOpen, onClose);
if (el === opener) crumb = crumbs[crumbs.length-1];
}
return crumb;
function onOpen(crumb: Breadcrumb) { crumb.el.toggleClass("is-exploring", true); }
function onClose(crumb: Breadcrumb) { crumb.el.toggleClass("is-exploring", false); }
}

function explorableCrumb(explorer: Explorer, opener: HTMLElement) {
const crumbs: Breadcrumb[] = [];
const parent = opener.matchParent("#quick-explorer");
let crumb: Breadcrumb;
for (const el of parent.findAll(".explorable")) {
new Breadcrumb(crumbs, el, app.vault.getAbstractFileByPath(el.dataset.filePath), onOpen, onClose);
if (el === opener) crumb = crumbs[crumbs.length-1];
}
return crumb;
function onOpen() {
explorer.isOpen++;
}
function onClose() {
explorer.isOpen--;
explorer.lastMenu = null;
if (!explorer.isOpen && explorer.isCurrent()) explorer.update(app.workspace.getActiveFile());
}
}

function hierarchy(file: TAbstractFile) {
const parts = [];
while (file) {
parts.unshift({ file, path: file.path });
file = file.parent;
}
if (parts.length > 1) parts.shift();
return parts;
}
60 changes: 38 additions & 22 deletions src/FolderMenu.ts
@@ -1,5 +1,5 @@
import { TAbstractFile, TFile, TFolder, Keymap, Notice, HoverParent, debounce, WorkspaceSplit, HoverPopover, FileView, MarkdownView, Component } from "obsidian";
import { hoverSource, startDrag } from "./Explorer";
import { TAbstractFile, TFile, TFolder, Keymap, Notice, HoverParent, debounce, WorkspaceSplit, HoverPopover, FileView, MarkdownView } from "obsidian";
import { Breadcrumb, hoverSource, startDrag } from "./Explorer";
import { PopupMenu, MenuParent } from "./menus";
import { ContextMenu } from "./ContextMenu";
import { around } from "monkey-around";
Expand All @@ -8,6 +8,7 @@ import { fileIcon, folderNoteFor, previewIcons, sortedFiles } from "./file-info"

declare module "obsidian" {
interface HoverPopover {
position(pos?: {x: number, y: number}): void
hide(): void
onHover: boolean
isPinned?: boolean
Expand Down Expand Up @@ -44,7 +45,7 @@ export class FolderMenu extends PopupMenu implements HoverParent {

parentFolder: TFolder = this.parent instanceof FolderMenu ? this.parent.folder : null;

constructor(public parent: MenuParent, public folder: TFolder, public selectedFile?: TAbstractFile, public opener?: HTMLElement) {
constructor(public parent: MenuParent, public folder: TFolder, public selectedFile?: TAbstractFile, public crumb?: Breadcrumb) {
super(parent);
this.loadFiles(folder, selectedFile);
this.scope.register([], "Tab", this.togglePreviewMode.bind(this));
Expand Down Expand Up @@ -87,7 +88,7 @@ export class FolderMenu extends PopupMenu implements HoverParent {

onArrowLeft() {
super.onArrowLeft();
if (this.rootMenu() === this) this.openBreadcrumb(this.opener?.previousElementSibling);
if (this.rootMenu() === this) this.openBreadcrumb(this.crumb?.prev());
return false;
}

Expand Down Expand Up @@ -162,10 +163,10 @@ export class FolderMenu extends PopupMenu implements HoverParent {
return this.items.findIndex(i => i.dom.dataset.filePath === filePath);
}

openBreadcrumb(element: Element) {
if (element && this.rootMenu() === this) {
const prevExplorable = this.opener.previousElementSibling;
(element as HTMLDivElement).click()
openBreadcrumb(crumb: Breadcrumb) {
if (crumb && this.rootMenu() === this) {
this.hide();
crumb.open();
return false;
}
}
Expand All @@ -176,7 +177,7 @@ export class FolderMenu extends PopupMenu implements HoverParent {
if (file !== this.selectedFile) {
this.onClickFile(file, this.currentItem().dom);
} else {
this.openBreadcrumb(this.opener?.nextElementSibling);
this.openBreadcrumb(this.crumb?.next());
}
} else if (file instanceof TFile) {
const pop = this.hoverPopover;
Expand Down Expand Up @@ -382,18 +383,33 @@ export class FolderMenu extends PopupMenu implements HoverParent {
// Position the popover so it doesn't overlap the menu horizontally (as long as it fits)
// and so that its vertical position overlaps the selected menu item (placing the top a
// bit above the current item, unless it would go off the bottom of the screen)
const hoverEl = popover.hoverEl;
hoverEl.show();
const
menu = this.dom.getBoundingClientRect(),
selected = this.currentItem().dom.getBoundingClientRect(),
container = hoverEl.offsetParent || this.dom.ownerDocument.documentElement,
popupHeight = hoverEl.offsetHeight,
left = Math.min(menu.right + 2, container.clientWidth - hoverEl.offsetWidth),
top = Math.min(Math.max(0, selected.top - popupHeight/8), container.clientHeight - popupHeight)
;
hoverEl.style.top = top + "px";
hoverEl.style.left = left + "px";
const reposition = () => {
const hoverEl = popover.hoverEl;
//hoverEl.show();
let
menu = this.dom.getBoundingClientRect(),
selected = this.currentItem().dom.getBoundingClientRect(),
container = hoverEl.offsetParent || this.dom.ownerDocument.documentElement,
popupHeight = hoverEl.offsetHeight,
left = Math.min(menu.right + 2, container.clientWidth - hoverEl.offsetWidth),
top = Math.min(Math.max(0, selected.top - popupHeight/8), container.clientHeight - popupHeight)
;
if (left < menu.left + (menu.width / 3) && menu.left > hoverEl.offsetWidth) {
// Popover hides too much of menu - move it to the left side
left = menu.left - hoverEl.offsetWidth;
}
popover.position({x: left, y: top});
hoverEl.style.top = top + "px";
hoverEl.style.left = left + "px";
}
if ("onShowCallback" in popover) {
around(popover as any, {onShowCallback(old) {
return function() {
this.hoverEl.win.requestAnimationFrame(reposition);
return old?.call(this);
}
}})
} else this.dom.win.requestAnimationFrame(reposition);
}
}

Expand Down Expand Up @@ -427,7 +443,7 @@ export class FolderMenu extends PopupMenu implements HoverParent {
}
} else if (file === this.selectedFile) {
// Targeting the initially-selected subfolder: go to next breadcrumb
this.openBreadcrumb(this.opener?.nextElementSibling);
this.openBreadcrumb(this.crumb?.next());
} else {
// Otherwise, pop a new menu for the subfolder
const folderMenu = new FolderMenu(this, file as TFolder, folderNoteFor(file as TFolder));
Expand Down
12 changes: 9 additions & 3 deletions src/menus.ts
Expand Up @@ -208,25 +208,31 @@ export class PopupMenu extends (Menu as new (app: App) => Menu) { // XXX fixme w
}

cascade(target: HTMLElement, event?: MouseEvent, onClose?: () => any, hOverlap = 15, vOverlap = 5) {
const {left, right, top, bottom, width} = target.getBoundingClientRect();
const centerX = left+Math.min(150, width/3), centerY = (top+bottom)/2;
const {left, top, bottom, width} = target.getBoundingClientRect();
const centerX = left + (target.matchParent(".menu") ? Math.min(150, width/3) : 0);
const win = window.activeWindow ?? window, {innerHeight, innerWidth} = win;

// Try to cascade down and to the right from the mouse or horizontal center
// of the clicked item
const point = {x: event ? event.clientX - hOverlap : centerX , y: bottom - vOverlap};

// Measure the menu and see if it fits
this.sort?.();
win.document.body.appendChild(this.dom);
const {offsetWidth, offsetHeight} = this.dom;
const fitsBelow = point.y + offsetHeight < innerHeight;
const fitsAbove = top - vOverlap - offsetHeight > 0;
const fitsRight = point.x + offsetWidth <= innerWidth;

// If it doesn't fit underneath us, position it at the bottom of the screen, unless
// the clicked item is close to the bottom (in which case, position it above so
// the item will still be visible.)
if (!fitsBelow) {
point.y = (bottom > innerHeight - (bottom-top)) ? top + vOverlap: innerHeight;
if (fitsAbove) {
point.y = top - vOverlap;
} else {
point.y = (bottom > innerHeight - (bottom-top)) ? top + vOverlap: innerHeight;
}
}

// If it doesn't fit to the right, then position it at the right edge of the screen,
Expand Down

0 comments on commit 17241f3

Please sign in to comment.