Skip to content

Repository files navigation

NoteSilo

A local-first note editor with a VSCode-style explorer and Notepad++-style line editing. Everything runs in the browser, and nothing is ever uploaded.

There is no backend, no account, no sync, and no telemetry. The app is a static bundle. Your notes live in this browser's IndexedDB and, if you want them somewhere sturdier, in a .json file on your own disk that the app keeps up to date as you type.


What it does

Organising

  • Groups and documents in a drag-and-drop tree; each group can have its own icon and colour, picked from the right-click menu
  • Groups and loose notes share one order, so a group can sit between two notes
  • Right-click menus for rename, duplicate, move between groups, and delete
  • Double-click a group to maximise it; the state is saved with your notes
  • Tabs with VSCode preview behaviour: browsing the tree reuses a single preview tab, while a note you create keeps its own. Pinning is always deliberate - nothing arrives pinned. Pinned tabs group to the left.
  • Double-click renames, in the tree and on a tab alike

Editing

  • Multi-cursor and column selection, with a setting that matches Notepad++ (Ctrl+Click for cursors, Alt+drag for a column) or VSCode (Alt+Click)
  • Duplicate, delete, and move lines; add text to the start or end of every selected line at once
  • Markdown syntax highlighting, with fenced code blocks coloured per language
  • A formatting toolbar, and a live preview pane
  • Editor text size and interface size scale independently, with steppers and keyboard shortcuts for each
  • Find and replace with regex, case, and whole-word toggles, plus a search across every document including the ones that are closed

Owning your data

  • Every edit is saved to this browser 600ms after you stop typing, so imported notes are safe immediately
  • Bind to a real .json file and it is rewritten after a five second lull
  • Detects when another program edits that file and asks what to do about it
  • Export and import plain, readable JSON at any time

Quick start

npm install
npm run dev        # http://localhost:5173
Script What it does
npm run dev Development server with hot reload
npm run build Typecheck, then build the static site into dist/
npm run preview Serve the built dist/ locally
npm test Unit tests for the parsing, search, and text helpers
npm run lint ESLint
npm run typecheck TypeScript, no emit

Browser support

Feature Chrome / Edge Firefox Safari
Editing, storage, search Yes Yes Yes
Bind to a file on disk Yes No No
Export and import JSON Yes Yes Yes

Binding uses the File System Access API, which only Chrome and Edge implement today. Elsewhere the app says so plainly and falls back to export and import rather than pretending the binding worked.

Deploying

netlify.toml is ready as-is: build npm run build, publish dist. Nothing else is needed, because there is nothing to run on a server. Any static host works; the Netlify file simply also sets the security headers described below.

NoteSilo installs as an app: it ships a web manifest with proper PNG icons (regular and maskable), so Install app in Chrome or Edge gives it a real icon and its own window instead of a browser-generated monogram.

Where your notes live

Location Written when Survives
IndexedDB 600ms after you stop typing Reloads and browser restarts
Bound .json file 5s after you stop typing Anything - it is your file
localStorage When you change a setting Reloads
Downloads folder File > Export as JSON Anything

Clearing your browser data deletes notes that only live in IndexedDB. Nothing is uploaded, which also means nobody else has a copy. Use File > Bind to file so a real file on disk is always current, or File > Export as JSON for a one-off backup.

Keyboard shortcuts

Documents Editing
Alt+N New document Ctrl+D Duplicate line
Ctrl+O Open a notes file Alt+Shift+K Delete line
Ctrl+S Save now Alt+Up / Alt+Down Move line
Alt+W Close tab Alt+Click Add another cursor
Alt+PageDown Next tab Ctrl+Alt+Up/Down Add cursor above / below
Alt+PageUp Previous tab Ctrl+G Go to line
Ctrl+B Toggle sidebar Ctrl+Alt+H Add text to line start
Alt+P Markdown preview Ctrl+Shift+Alt+H Add text to line end
Ctrl+, Settings Ctrl+Shift+L Cursor at every match
Ctrl+Shift+H Regex panel Shift+Alt+I Cursor at each line end
Alt+Shift+T Toggle theme Ctrl+/ Toggle line comment

Find is Ctrl+F, find and replace is Ctrl+H, and Ctrl+Shift+F searches every document including closed ones. In the search panel Enter and Shift+Enter step through matches, and tabs containing a match are underlined. Ctrl+Shift+H opens the regex find and replace panel described below.

Text size and interface size

These are two separate controls, because wanting bigger prose to write in is not the same as wanting bigger menus.

Control Range Steppers in Shortcut
Editor text 10 - 24 px Settings Alt+= / Alt+-
Interface 80 - 160 % Settings Alt+Shift+= / -

Alt+0 resets the editor text and Alt+Shift+0 resets the interface. The status bar shows both as 14px / 100%; clicking it opens Settings.

Interface size scales the menu bar, tab strip, sidebar, status bar, icons and hit targets together, from a single --ui-scale custom property. It deliberately does not touch the editor, which follows the text size instead.

The layout is built to survive browser zoom as well. The sidebar is clamped against the viewport rather than fixed at 250px, the toolbar and menu bar scroll rather than clip, status bar counts drop out narrowest-first, and below 720px the sidebar folds to its icon rail on its own.

Selecting more than one line

Alt+Click drops an extra cursor, and Ctrl+Alt+Up / Ctrl+Alt+Down stack them above or below. For a rectangular block the default is Shift+Alt+drag.

Coming from Notepad++, open settings and set Multiple cursors to Ctrl+Click. That swaps the pair over, so Alt+drag selects a column exactly as it does there. Help > Keyboard shortcuts always lists whichever pair is currently active, so the panel never shows you a key that does nothing.

The Edit menu also has a Column selection mode toggle, which turns every ordinary drag into a block selection until you switch it back off.

Lines of different lengths

Stacking cursors down a ragged block is where most editors get annoying: you put the cursor at the end of a long line, press Ctrl+Alt+Down, and the new cursor lands in the middle of the shorter line below because it kept the column.

NoteSilo does not do that. When the cursor is already at the end of its line, Ctrl+Alt+Down and Ctrl+Alt+Up put each new cursor at the end of its own line, however ragged the block is. Anywhere else in a line the usual keep-the-column behaviour applies, so nothing you already rely on changes.

Two more ways to get the same result:

  • Select the lines and press Shift+Alt+I for a cursor at the end of every one.
  • With cursors already placed anywhere, press End. Each one jumps to the end of its own line.

And if you only want to append the same text to a set of lines, you do not need cursors at all: select them and press Ctrl+Shift+Alt+H, which appends to every selected line in a single undo step. Ctrl+Alt+H does the same at line starts.

Why Alt instead of Ctrl

Browsers act on some Ctrl combinations before a web page ever receives the event, and no amount of preventDefault gets them back: Ctrl+N opens a window, Ctrl+W closes the tab, Ctrl+T opens one, Ctrl+Tab and Ctrl+Shift+Tab switch browser tabs, Ctrl+Shift+T reopens a closed one, and Firefox takes Ctrl+Shift+K for its developer console. Every command that would have used one of those is on Alt instead.

The Ctrl shortcuts that remain - S, O, F, H, G, D, B, ,, / - are ones a page can reliably intercept, so they do what you expect and the browser's own action never fires.

Bindings are matched on physical key position, so they keep working on layouts where holding Alt changes which character a key produces.

Regular expressions

NoteSilo has a Regex panel in the sidebar - open it with Ctrl+Shift+H, the .* button in the sidebar header, or Edit > Regex find and replace. It holds a pattern field, a replacement field, a live match count across every document, a syntax reference, and ready-made patterns you can click to load.

Replacement runs either on the current document or, behind a confirmation that shows the exact count, on every document at once - including the ones that are closed. The editor's own widget (Ctrl+F, Ctrl+H) still handles single-document work.

Where Open with Turn regex on
Find in this document Ctrl+F the .* button, or Alt+R
Find and replace Ctrl+H the same .* button
Search every document Ctrl+Shift+F the .* button in the panel
Regex panel Ctrl+Shift+H always on

The flavour is JavaScript's own RegExp, searching line by line, so ^ and $ anchor to the start and end of a line rather than the whole document.

Replacing with capture groups

In the replacement, $1 and $2 refer to capture groups and $& to the whole match.

Goal Find Replace
Strip trailing whitespace \s+$ (empty)
Collapse runs of blank lines \n{3,} \n\n
Unwrap markdown links to their text \[([^\]]+)\]\([^)]+\) $1
Turn Last, First into First Last (\w+), (\w+) $2 $1
Make bare URLs into autolinks (https?://\S+) <$1>
Renumber a list to all ones ^\d+\. 1.

Each of these is in the panel's Ready-made patterns list: click to load it into the fields, check the match count, then replace.

Ctrl+Shift+L puts a cursor on every match of the current selection, which is often quicker than a replace when the change is small.

Behaviour worth knowing

  • The three toggles combine. Whole word wraps your pattern as \b(?:...)\b, so it stays correct even when the pattern contains alternation.
  • An incomplete pattern is not an error. While you type ([a-z, the search reports no matches and the panel says the expression is not valid yet. Nothing throws, and nothing is replaced.
  • A pattern that can match nothing, such as x*, will not hang the search - the matcher steps past zero-length matches instead of looping on them.
  • In a replacement, \n and \t become a real line break and tab. Without that there would be no way to type them into a single-line field.
  • Outside regex mode a typed $ stays literal, so replacing a price does not quietly become a capture-group reference.
  • Replacing across every document asks first, and tells you how many matches in how many documents it is about to change.

The notes file

The saved JSON is meant to be readable and editable by hand, and by other tools.

{
  "version": 2,
  "theme": "dark", // settings live at the top level
  "fontSize": 14,
  "fontFamily": "Monaco",
  "lineHeight": 1.5,
  "tabSize": 2,
  "wordWrap": true,
  "minimap": false,
  "sidebarCollapsed": false,
  "maximizedGroupId": null,
  "activeDocId": "doc-1",
  "groups": [
    { "id": "group-1", "name": "Work", "collapsed": false, "color": "#3498db", "docs": ["doc-2"] },
  ],
  "rootOrder": [
    // the top level, interleaved
    { "kind": "doc", "id": "doc-1" },
    { "kind": "group", "id": "group-1" },
  ],
  "openTabs": [{ "docId": "doc-1", "pinned": true }],
  "docs": {
    "doc-1": {
      "id": "doc-1",
      "name": "README",
      "type": "markdown",
      "content": "# Hello",
      "createdAt": "2026-08-30T10:00:00.000Z",
      "updatedAt": "2026-08-30T14:30:00.000Z",
    },
  },
}

Anything the app reads is repaired rather than trusted: ids that no longer point at a document are dropped from groups and tabs, out-of-range settings are clamped, and a document that nothing mentions is recovered into the root instead of being lost. See src/utils/validators.ts.

Version 1 files, which kept a separate rootDocs list, open unchanged: the root order is rebuilt as "every group, then the loose notes", which is exactly how version 1 drew them.

How it is put together

React 19, TypeScript, Vite, Zustand for state, and Monaco for the editor.

src/
  components/    Sidebar, TabBar, Editor, StatusBar, MenuBar, dialogs
  hooks/         autosave, theme, file watching, global shortcuts
  store/         appStore.ts - one Zustand store for documents and UI state
  utils/         storage, file handling, markdown, search, validation
  monaco-setup.ts  Monaco wiring, keybinding rules, and the two editor themes
tests/           unit tests for the pure logic

A few decisions worth knowing about:

Monaco is bundled, never fetched. @monaco-editor/react downloads Monaco from a CDN by default, which would be an outbound request on every page load. src/monaco-setup.ts hands the loader the local npm copy instead. The CDN URL still appears as a dead string inside the bundle; the loader resolves with the injected instance and returns before it could ever be used.

Monaco's language services are stripped. The TypeScript, CSS, HTML, and JSON services ship multi-megabyte workers for IntelliSense on languages this app never sets on a model. vite.config.ts aliases them away, which removes about 9MB from the build. Fenced code blocks are still highlighted, because that comes from the Monarch grammars, which are kept.

Find and replace is Monaco's own widget. It already does regex, case, whole word, match counts, next and previous, and replace-all. The custom search panel covers the part Monaco cannot: looking inside documents that are not open.

Markdown is sanitised twice. markdown-it runs with html: false, so raw HTML in a note is escaped rather than parsed, and the result then goes through DOMPurify. Code is only ever wrapped in span elements; nothing is executed.

Deleting a group never deletes notes. Its documents move out to the root, and the confirmation dialog says so.

Checking the privacy claim yourself

You do not have to take any of this on trust:

  1. Open DevTools, go to the Network tab, and use the app. After the initial load of the page's own files, it stays empty - including while typing, saving, and searching.
  2. Turn off your network connection entirely. Everything keeps working.
  3. Search the built output yourself. Run npm run build, then grep dist/assets/ for XMLHttpRequest, sendBeacon, or WebSocket: there are no matches at all. fetch( matches three times, and none of them reach another origin - one is Vite's modulepreload polyfill requesting the app's own chunks, and the other two are a method that happens to be named fetch on Monaco's inline-completion source, which is not the network API and has no providers registered.
  4. In production the Content-Security-Policy in netlify.toml enforces it regardless of the code: connect-src 'self' means the browser itself refuses to let the page send anything to another origin.

Limitations

  • Binding to a file needs the File System Access API, so Chrome or Edge. Other browsers get export and import instead.
  • External-change detection polls the bound file every 3 seconds while the tab is visible, because the API offers no change notification.
  • The editor chunk is ~4.3MB (~1.1MB gzipped). That is Monaco, and it is cached after the first visit.
  • main.html in the repo root is the original single-file prototype. It is left untouched and excluded from the build, lint, and formatting.

License

No license has been chosen yet. Add a LICENSE file before publishing this anywhere public, since without one nobody else has permission to use it.

About

A note editor that never phones home. Runs entirely in the browser and saves to your own .json file — no backend, no sync, no analytics.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages