Blog

Atom Feed

Behind Homepage

The story of developing the Homepage plugin for Obsidian.

20 Dec 2024

I'm the developer of the Homepage plugin for the Obsidian notetaking app. One of the most popular plugins within the community repository, it adds the ability to designate certain notes, workspaces, canvases, etc. to open on startup or otherwise perform actions when opening a vault.

This may appear to be a simple and painless affair, but there's a surprising amount of complexity involved. For experienced developers, much of this will not come as a surprise, but for those who are learning the ropes of plugin development, or are otherwise interested, I hope this is somewhat informative.


Origins

I initially started using Obsidian as a replacement to my TiddlyWiki file. While TiddlyWiki is great in many respects, its single-file structure is hard to use in certain circumstances and not very portable. To work around this, I used the official TiddlyDesktop app, but it launches on a wiki chooser every time, making quick notetaking rather difficult. As I continued using my wiki, I found myself gravitating towards plain text files for quick notes unrelated to bigger interlinked documents. With Obsidian, these could be transformed to Markdown with just a few quick edits, and co-exist with the rest of my notes. And if I was the midst of coding in my text editor, I could still start a note in the same app and then add it to my Obsidian vault when done.

However, there was one dealbreaker with Obsidian. TiddlyWiki can start on a designated 'tiddler';1 I had made a custom-built 'Home' tiddler with a set of quick links, and this became a core part of my workflow.2 But Obsidian has no inbuilt mechanism for this, meaning that I had to wade through whatever I was I was last doing everytime I opened it. Fortunately, Obsidian had an then-incipient plugin system. Since I already knew how to program, it was very easy to start a new plugin using the official template. Although it took me a while to get up to speed with the API (and figure how to avoid loading the homepage when the plugin is installed), the first version ended up being very simple:

export default class Homepage extends Plugin {
    settings: HomepageSettings;

    async onload() {
        await this.loadSettings();
        this.addSettingTab(new HomepageSettingTab(this.app, this));

        this.addCommand({
            id: "open-homepage",
            name: "Open Homepage",
            callback: this.openHomepage,
        });

        if(this.app.workspace.activeLeaf == null) {
            //only do on startup, not plugin activation
            this.app.workspace.onLayoutReady(this.openHomepage);
        }
    }

    //continued...

It was rudimentary and its logic was really simple. It went something like this:

It was the proverbial Model T of plugins - the only option was to change what note was the homepage. After it flew past the submission process, I expected that it would get around 3 users. After all, there were already quite a few plugins, and nobody had developed something similar. Instead, its user base grew many times bigger than I would have ever expected.

Naïvely, I didn't consider that many of them would have lots of different needs and demands that didn't fit within that model, and before long, I found myself adding features that I initially rejected: Daily Notes support, "Use when opening normally", and retaining notes in the sidebar, among others. Hubris and myopia are powerful things.

Those additional users and extra bells and whistles are nice for bragging rights, but with them the plugin also grew massively in lines of code, complexity, and features. I learned a lot, but gained a lot of extra responsibility as well.

Side Note: Dataview in Homepage

My TiddlyWiki homepage also had several other special properties - the time of the latest edit made to the wiki, and the total number of tiddlers that existed in the wiki. In TiddlyWiki, this is accomplished like so:

<b>Last updated on <$list filter="[!is[system]!has[draft.of]!sort[modified]limit[1]]"> <$view field="modified" format="date" template="DDth MMM YYYY at hh12:0mmam"/> </$list></b><br>
<b><$count filter="[!is[system]]" /> pages on this wiki</b>

Although I considered creating a plugin to implement a system of magic words similar to TiddlyWiki's widgets, I realised that it was very easy to write something equivalent with Dataview's built-in JavaScript capabilities:

let all = dv.pages("");
let lastmod = all.sort(a => a.file.mtime).last().file.mtime.toFormat("dd LLL yyyy 'at' h:mma");
let count = all.length;

dv.paragraph(
    `<div class="nv-subtitle">Last updated on ${lastmod}<br/>${count} pages in this vault</div>`
);

Homepage and Dataview are a popular combination nowadays, with many flashy and photogenic homepages far beyond what I ever imagined. Although they were intended to be used together from the very start, I later added an extra setting to ensure that Dataview statistics are always refreshed (depending on your use case, the original refresh rate may be sufficient).


Optimisation

Although nothing Homepage does is particularly intensive, it has a very high performance bar: users shouldn't have to wait to interact with their homepage. Opening a file may be quick, but when wrapped in Obsidian's panes and models it can take a little longer. And it's always prudent to avoid useless and repetitive work.

The primary path for Homepage - the original method of clearing extant notes, and opening a new note - is designed to be simple, and to involve little intensive work. Although there's a lot more conditionals and steps that there initially were, the path from the initial HomepagePlugin.onload() to the actual opening of the vault should be as straightforwards as possible. Ideally, anything extra is gated behind a sole condition which then leads to the implementation, and any additional processing is only done then.

Obsidian's core functions are closed-source, but they can still be introspected via the Chromium developer tools. For many plugins, this isn't necessary, and you can stick to the intended model of being handed the API as a mysterious black box, where all the difficult stuff is out of sight and out of mind. But for Homepage, it has been very helpful.3

As an example, let's look at the code that closes all other tabs when "Replace all open notes" is selected. We want to close all tabs on a certain list of known tab types, as Obsidian has tabs like the file browser we want to retain.4 This seems simple: there is the function app.workspace.detachLeavesOfType, so we run through the types of leaves we want to close:

CLOSED_LEAVES.forEach(t => this.app.workspace.detachLeavesOfType(t));|

What isn't apparent from the public-facing API is that Obsidian keeps a list of all the open leaves, but it doesn't track them by type. Usually, the user opens and closes tabs a lot more than they do something that requires tabs of a certain type, so tracking tabs by type is suboptimal. Instead, Obsidian simply does this:5

function getLeavesOfType(viewType: string): WorkspaceLeaf[] {
    let leaves = [];
    iterateAllLeaves(leaf => {
        if (leaf?.view?.getViewType() !== viewType) return;
        leaves.push(leaf);
    })
    return leaves;
}

function detachLeavesOfType(viewType: string): WorkspaceLeaf {
    for (const leaf of this.getLeavesOfType(e)) {
        leaf.detach()
    }
}

Obviously, we don't want to be iterating through the whole set of leaves multiple times, as it's an expensive operation. Instead, we can simply call iterateAllLeaves directly, as it's also a publicly exposed function.6 In the callback, we check whether each leaf has a type we want to get rid of once:

let leaves = [];

app.workspace.iterateAllLeaves(leaf => {
    if (CLOSED_LEAVES.contains(leaf?.view?.getViewType())) leaves.push(leaf);
});

leaves.forEach(l => l.detach());

In fact, iterateAllLeaves just calls a few other internal functions to iterate over different parts of the window. If we narrow it down to iterateRootLeaves, this also ignores the side panels containing the file browser and its cohorts. The tradeoff is that we can't pick what tab types to close, but the few special tab types can be handled separately.

let leaves = [];

app.workspace.iterateAllLeaves(l => leaves.push(l));
leaves.forEach(l => l.detach());

But it still isn't ideal. We've closing each tab separately, so the visual state is wastefully updated after each modification.7 Instead, the changeLayout function modifies it in one fell swoop. When looking at the code for the popular built-in Workspaces plugin, which is proven to work without many hitches, we can see it is used there too.


Development and testing

Most plugins perform their work after the vault is loaded in response to user action, so much of the API is predicated on giving Obsidian time to set up. Homepage acts differently - while it can be manually triggered by user action, it is usually automatically launched on startup. If Obsidian is still loading, Homepage can break in difficult to reproduce ways without carefully considered and tested code.

The main method of testing Obsidian plugins to use an off-the-shelf JavaScript testing framework and mock Obsidian's proprietary application code. But for Homepage, which depends on behaviour of Obsidian functions which may not work predictably while the vault is still loading, this does not work. Additionally, while many other plugins have complex logic which can easily be extricated from parts that interface with Obsidian, Homepage performs almost all its actions by putting together core Obsidian functionality. Therefore, Homepage uses a custom test runner that runs inside the Obsidian vault instead.8

While Obsidian can launch vaults in an automated fashion, it can only do this for known vaults (as of this writing). Therefore, the test vault must be opened manually once. Then the rest is automated via an augmented version of the plugin, which automatically runs through different test scenarios after the vault is initialised. It handles basic testing functionality, like failing on errors, built in methods for assertion accessible from each test function, etc. The testing cycle runs like so:

Each test suite is its own class, which are all loaded into HomepageTestPlugin. A basic test looks like this:

async replaceAll(this: HomepageTestPlugin) {
    await this.app.workspace.openLinkText("Note A", "", false);

    this.homepage.data.manualOpenMode = Mode.ReplaceAll;
    this.homepage.save();
    this.homepage.open();

    const file = this.app.workspace.getActiveFile();
    const leaves = this.app.workspace.getLeavesOfType("markdown");
    this.assert(file?.name == "Home.md" && leaves.length == 1, file, leaves);
}

Additionally, Homepage incorporates a few additional functions which are included in non-production builds which are designed to be easily accessed in the console for debugging. With ESBuild, these are automatically excluded from production builds:

declare const DEV: boolean; 
if (DEV) import("./dev");

User experience

One of the most challenging parts of making Homepage isn't making features work, or deciding how to implement things, but designing how users interact with the plugin. Although the core workflow is very simple, the user interaction for complex settings needs to be intuitive.

A hallmark feature of Homepage 3.010 was supposed to be the ability to create and switch between arbitrary number of homepages. Any of these homepages could be set as a desktop or mobile homepage, and possibly as a homepage to be opened up on startup, or to be opened via commands or buttons (with the user being able to create an arbitrary amount of them, instead of just one). The feature for a manual opening mode would be obviated: instead, the user would just create a whole new homepage.

This would open up a whole new set of use cases for the plugin, and plenty of people who don't want something set automatically on startup would find it useful. In fact, the data model for Homepage 3.0 and later is designed around this, and it is used for having a separate mobile homepage:

{
    version: 4,
    homepages: {
        "Main Homepage": {
            value: "Home",
            kind: Kind.File,
            openOnStartup: true
            ...
        },
        "Mobile Homepage": {
            value: "Mobile Home",
            kind: Kind.File,
            openOnStartup: true
            ...
        }
    }
    ...
}

But the main reason why the feature hasn't been implemented is the difficulty of designing an intuitive user workflow for it. Having an interface that is intuitive, easy to understand, and has no extraneous details is more important than complicating it for a feature that a good portion of users won't care about or use.

Right now, making sure that the homepage doesn't close all other tabs when opening it from a command is as simple as setting a "manual opening mode". Making it the responsibility of separate homepages is easy to code - most of the work has already been done - but makes for a more annoying user interface. Every time the user wants to change another unrelated setting, they now have to change it for two homepages. I could create a default homepage that all the others inherit from, but that's more added complexity that users have to wrap their heads around. Sometimes simple is better.

One of my favourite pieces of user interface for Homepage is the command box. The add button is intentionally in line with the commands, signifying that it's in the place of where an added command is going to go. The inline nature of the different command pills nicely indicates how the commands are run in order one after another, while being more compact than simply dedicating a row to each command. Plus subjectively I think it looks pretty.

A challenge was making it support additional options in version 4.0. In versions beforehand, the commands UI looks like this:

The commands UI, as introduced in version 3.0.

4.0 added the ability to set certain commands as only activating during startup or when opening the homepage manually. This was intended to serve some of the same purposes as creating arbitrary homepages - people might want to use commands at certain times but not others. Initially, I considered making this option a dropdown on a separate line, in slightly smaller text:

Although these mockups were made in Affinity Designer post-facto, each layout was actually tested.

But in practice, these dropdowns are extremely small and hard to target. If they were bigger, they wouldn't be subordinate to the main titles, and if you made the whole thing bigger, it would take up too much space. Plus, this removes the visual conveyance of the commands being in sequential order - the user is now reading the dropdown and buttons on the bottom, then going back to the top, then back down, and so forth.

My next solution was to add icons for the different states. I also gave the non-default settings an accent colour to help them stand out, and to convey that something is "active" that isn't the normal setting. It looked like this:

It wouldn't be unreasonable to assume that ⏻ means that the command is executed when closing your vault.

This is definitely more compact and less obtrusive. But even after testing a lot of icons, it's difficult to tell what they mean, and the small size means they've difficult to notice. While it's a fairly advanced feature, so a size smaller than the default Obsidian UI elements is acceptable, this is too far in that direction. Eventually, I went with this:

The final commands UI in version 4.0 and later.

At first, I considered this somewhat non-ideal: the button changes size, and using text is almost cheating! But the button needs to be non-intrusive for the majority of users which aren't interested in an advanced feature, but distinct and descriptive for those who are using it. So having it change size is kind of a necessary evil, and there's precedent for using text in this way. The icons almost stayed in, but in such a compact UI I found they were additional visual noise, so they were dropped at the last minute.11