Notepadia architecture¶
Principle¶
Notepadia reproduces the Notepad++ user experience without reproducing the Notepad++ internal implementation.
Applications¶
applications/browser— the primary development target. A Theia browser application whose Node backend serves the frontend athttp://localhost:3000. No native rebuild is required per edit; iterate withyarn watch+yarn start.applications/electron— the native desktop packaging target (Electron + Electron Builder). Used only when producing distributables (AppImage/RPM/DEB on Linux, NSIS/portable on Windows, DMG on macOS).
Both load the same notepadia extension; product logic lives there.
Theia owns:
- editor lifecycle
- Monaco integration
- filesystem
- workspaces
- navigator
- command registry
- keybindings
- menus
- preferences
- search
Notepadia owns:
- product menus and shortcuts
- Notepad++-specific editing behavior
- encoding UX
- EOL UX
- session/recent-file UX
- status bar presentation
- product branding
- desktop packaging choices
- automatic updates
Dependency rule¶
Do not depend directly on private Theia implementation modules unless the public Theia API does not provide the required behavior and the dependency is documented with a migration note.
Notepad++ editing essentials (P1)¶
Menus and commands¶
NotepadiaContribution, NotepadiaMenuContribution,
NotepadiaKeybindingContribution, and NotepadiaEditorKeybindingContribution
in extensions/notepadia/src/browser/ implement the Notepad++-style surface:
- top-level menus
Search,Encoding,Language,Settingsplus Notepad++ sections insideFile(New / Open / Save / Save As / Save All / Close / Close All / Close All But Active) andEdit(Indent / Unindent / Duplicate Current Line / Delete Current Line / Move Current Line Up+Down / Join Lines / comment line, aLine Operationssubmenu, and aConvert Casesubmenu). - editor actions are executed through
control.trigger('notepadia', 'editor.action.*', null)on the current Monaco editor, so text manipulation reuses Monaco's hardened editing code paths instead of bespoke string rewriting. Searchmenu: Find / Find Next / Find Previous / Replace / Find in Files (the working command id for the latter issearch-in-workspace.open).
Keybindings and the editor input path¶
Editor chords can arrive through two different input implementations:
- textarea path (classic) — DOM keydown events fire and Theia's own
keybinding registry intercepts them at document capture. Notepadia bindings
are registered via
KeybindingContributionand work as usual. - native EditContext path — contemporary Chromium supports the native
EditContextAPI and Monaco opts into it by default (editor.editContextmonaco option defaults totrue). That path never dispatches DOM keydown events, so Theia's keybinding layer cannot see editor chords. Monaco-levelStandaloneKeybindingService.addDynamicKeybinding(...)bindings cover it.
Decisions taken for P1:
NotepadiaEditorKeybindingContributionforces every editor back onto the classic textarea input withcontrol.updateOptions({ editContext: false })as each editor becomes current. The textarea path makes the Theia-level bindings deterministic across browsers (prerequisite for the two chords below). The Monaco dynamic-keybinding bridge is kept as a fallback for any input path where Monaco dispatches first; it intentionally cannot double-fire because Theia stops DOM propagation whenever it matches.- Monaco's default editor keybindings are mirrored into Theia's own
keybinding registry by
MonacoKeybindingContribution(from@theia/monaco), with aneditorTextFocuswhenclause. ChoosingselectBindingByLocalContexttherefore prefers those defaults over an identical-scope binding with nowhen. To win the two colliding chords, the Ctrl+D (duplicate line vs. MonacoaddSelectionToNextFindMatch) and Ctrl+L (delete line vs. MonacoexpandLineSelection) bindings are registered with the sameeditorTextFocuswhen; since they are also registered later (higher priority), the keybinding tree selects them first while the chords stay free outside the editor. - Theia defaults checked rather than replaced: Monaco's find widget is kept (F3 / Shift+F3 map to Find Next / Find Previous).
Unsaved-change close confirmation and OS drag-and-drop¶
NotepadiaCommands.CLOSE(notepadia.close) wraps Theia's close-tab with a Save / Don't Save / Cancel dialog when the current editor is dirty, and the File > Close menu item points at it.CLOSE_ALLdoes the same and offers Save All. AcloseInProgressguard prevents re-entrancy.- Theia's browser app does not open OS-dropped files.
NotepadiaDropContributioncatches window-level drops that no other component handled and offers the built-in "Upload Files..." flow (file.upload) so dropped files can be copied into the workspace. - Notepad++ never auto-saves. The stock Theia default is autosave on window
change, so a companion workspace setting
files.autoSave: "off"is recommended (shipped in the test workspace at.theia/settings.json).
Encoding conversion¶
NotepadiaEncodingContribution gives the Encoding menu Notepad++-style
entries, all backed by Theia's iconv-lite pipeline rather than a new codec:
Encode in UTF-8,Encode in UTF-8 BOM,Encode in UTF-16 LE,Encode in UTF-16 BE,Convert to ANSI (Windows 1252)calleditor.setEncoding(id, EncodingMode.Encode), which saves the buffer with that encoding (EncodingMode.Decodeis used byReload as UTF-8).Change File Encoding...links the built-in quick-pick (reopen/save with any supported encoding, including the ISO-8859 family).- The status bar shows the current encoding and EOL of the active editor and is clickable to change encoding.
- BOM handling: Theia's
EncodingService(iconv-lite) detects/emits UTF-16 LE, UTF-16 BE and UTF-8 BOMs. UTF-16 conversions therefore round-trip as-is.
Known pipeline limitation and workaround: when writing, EncodingRegistry
collapses utf8bom to utf8 before the BOM decision is made, so a UTF-8 BOM
encode writes plain UTF-8. ensureUtf8Bom() in the encoding contribution
patches the file bytes with the EF BB BF prefix right after such an encode,
keeping the editor model's utf8bom content encoding intact (a subsequent read
detects the BOM again). Revisit once upstream getEncodingForResource stops
returning the iconv name.
EOL conversion¶
NotepadiaEolContribution adds Notepad++-style line-ending conversion:
Edit ▸ EOL Conversion ▸ Convert to Unix Format (LF) and Convert to Windows
Format (CRLF) transform the buffer with Monaco's ITextModel.setEOL
(undoable on the model), and Change Line Endings... (also reachable by
clicking the EOL entry in the status bar) offers the same targets in a
quick-pick.
- Only LF and CRLF are offered. Monaco's line model splits exclusively on
\n, so classic CR cannot be represented as a line separator inside the editor; CR files are still detected and shown asCRin the status bar. - The status bar EOL/encoding entries refresh on both encoding changes and content changes, so conversions update live.
- Theia's built-in
EditorCommands.CONFIG_EOL(textEditor.commands.configEol) is a command stub without a handler in this version, hence the dedicated commands above.
Recent Files¶
NotepadiaRecentFilesContribution tracks opened editors (most recent first,
deduplicated, capped at 15) and renders them under File ▸ Recent Files with a
Clear Recent Files entry:
- The submenu is only present while the list is non-empty and is rebuilt on
every open/clear; commands
notepadia.recent.Nand their menu actions are registered/disposed dynamically, so the menu never shows stale entries. - History survives (re-)loads via
StorageService(localStorage, keynotepadia.recentFiles). Clicking an entry reopens the file and bumps it to the top. - Theia 1.75 has no recent-files support, hence the custom implementation.
Bookmarks¶
NotepadiaBookmarkContribution implements Notepad++-style bookmarks:
Ctrl+F2toggles a bookmark on the current line,F2/Shift+F2jump to the next/previous bookmark (wrapping within the file), andEdit ▸ Bookmarks ▸ Clear All Bookmarksempties the current file's set.- Bookmarks are per-model (keyed by URI, sorted line numbers), session-scoped
(Notepad++ keeps them per-session too) and rendered in the line-number gutter
via a Monaco decoration collection (
linesDecorationsClassName) with a CSS glyph injected at startup. - One decoration collection is cached per editor (IEditorDecorationsCollection)
and reused via
.set(), so toggling updates in place instead of stacking decorations; a fresh collection is created when an editor hosting the same model becomes active. - Monaco 1.108 removed
setModelDecorations, hencecreateDecorationsCollection.
Note: menu-hovering must stay within the window — the Bookmarks entry sits at the bottom of the Edit menu, so puppeteer suites need a viewport taller than the menu (>= ~640px) for hover/submenu interactions.
Search Results window (F7)¶
NotepadiaSearchResultsWidgetis aReactWidgetonWidgetManager.getOrCreatewith idnotepadia.searchResults, added to the shell's bottom area on first use.NotepadiaSearchResultsContributionbinds F7 / F4 / Shift+F4, registers the four commands and puts them inmenubar ▸ 3_search; the toolbar and command handlers call back into the same singleton, so F7 works before the panel has ever been opened.- Producers are the dialog's two Find All buttons and Find in Files' Find All.
NotepadiaSearchDialogWidgetowns aSearchInWorkspaceServicebinding (the only user of that DI binding in the extension) so the dialog publishes to the window rather than calling it directly, which keeps widget construction in the binding container. - Document hits come from Monaco's own search, so positions are converted with
monacoToTheiaPosition. Workspace hits come fromSearchInWorkspaceService.searchWithCallback, which reports matches but never how many files it read, so those headers stop atin N files. Replace Allis deliberately not a producer: it runs on Theia'ssearch-in-workspaceservice, whose replace path is separate from the search callback, and results would appear after the edits rather than drive them.- The tree keeps one cursor, so F4 / Shift+F4 step through a flat list of visible hits and wrap; with no row chosen it starts from the editor's caret.
Incremental Search (Ctrl+Alt+I)¶
NotepadiaIncrementalSearchWidget is a ReactWidget on WidgetManager id
notepadia.incrementalSearch, created through a WidgetFactory binding and
docked into the shell's bottom area on first use, mirroring the Search Results
window. NotepadiaIncrementalSearchContribution binds the toggle command, the
ctrlcmd+alt+i keybinding and the Search ▸ Incremental Search menu entry
(order i.5, between Matching Bracket and the Search Results entries).
- Every keystroke re-runs the search from the session anchor (the caret
captured when the bar opened) with
findNextMatch/findPreviousMatchand wrap; the term is resolved with the B2resolveSearchhelper and the mode fromNotepadiaSearchMarkContribution.searchMode(), so mode and Match case come from the same shared sources the Find dialog uses, and the term is written back toNotepadiaFindState. Enter/Shift+Enter(and the ▲/▼ buttons) step by one occurrence and mark the session as accepted.Escapecloses the bar and, unless a match was accepted, restores the anchor caret via theinitialSelectioncaptured on open.- Highlight all is one
IEditorDecorationsCollectionreused with.set()(the same pattern as bookmarks and search-mark), styled bynotepadia-incremental-highlight. A singleonKeyDownon the container div owns the Escape / Enter handling, so Escape dismisses the bar no matter which control inside it has focus. - A
no-matchclass paints the field with--theia-inputValidation-errorForeground, and arole="status"region announcesno matchso the state is audible as well as visible. ReactWidgetonly renders on an update request once attached, and the shell's bottom-dock activation is asynchronous, so the widget forces an early render inonAfterAttachand the contribution's focus loop keeps requesting focus on the term field until it sticks - otherwise the first keystrokes would land in the editor.
Language menu¶
NotepadiaLanguageContribution exposes Notepad++-style language handling (the
Language top-level menubar entry is both registered and populated by this one
contribution):
Change Language Mode...wraps the built-in command (textEditor.change.language), which is otherwise hidden from palettes and menus by its ownisVisibleregistration gate. It opens Monaco's own language quick-pick, which enumerates every registered language.- Curated language list (JavaScript, TypeScript, HTML, CSS, Markdown, YAML,
XML, Python, C, C++, C#, Java, JSON, PHP, PowerShell, Ruby, Go, Rust, Shell
Script, SQL, Plain Text). Each entry is a menu action calling
editor.setLanguage(id), matching Notepad++'sLanguagemenu. - Because this build ships almost no registered languages (~
plaintextandjsonconly), the contribution registers the listed languages at startup (monaco.languages.register({ id, aliases, extensions })) and attaches a lightweight Monarch tokenizer (monaco.languages.setMonarchTokensProvider) built from each entry's keyword/comment/string configuration. Registration is skipped for ids that already exist, so core languages are never overridden. - The extra extensions give Monaco file-type auto-detection for free
(
.js→ JavaScript,.sh→ Shell Script, ...).notepadiaLanguageName(id)provides the friendly alias for the status bar. - The status bar shows the friendly language name of the current editor and is
clickable (opens the change-language quick-pick); it refreshes on
onLanguageChanged, content changes, and encoding changes.
Enumeration caveat: Monaco's language service resolves a language by id only
once it is registered (TextMate grammars come from @theia/textmate-grammars,
which is not installed in this build). The built-in Monaco editor core ships no
vs/language data, so without a grammar package the Monarch tokenizers above
are the only real tokenization available; keyword/string/number tokens are
emitted (mtk* classes) but the stock theme only colors strings and numbers.
Function List panel¶
NotepadiaFunctionListWidgetis aReactWidgetwith idnotepadia.functionList, created byWidgetFactoryon first use and docked in the shell's right area by the sharedtogglePanelWidgethelper (Shell.addWidget(..., { area: 'right' })thenactivateWidget), the same helper the Document List and Character Panel use. The contribution owns thenotepadia.functionList.togglecommand, itsisToggledanswer (getAreaFor+isExpanded+widget.isVisible) and theView > Function Listitem; the toolbar item calls the same command, so both paths toggle one singleton.- Parsing lives in
common/function-list-rules.tsand is deliberately not a parser:parseFunctionList(text, languageId)runs one regex per language over the text, skipping lines whose trimmed form starts with the language's comment prefix. A per-rule table maps language id to{ kind, name, line }regexes, and the table is bounded twice -MAX_RULE_LINE_LENGTH(500) drops absurdly long lines andMAX_FUNCTION_ENTRIES(5000) caps what one file can produce - so the editor'sonDidChangeContentcannot be turned into a stall by a huge or generated file. - The widget holds exactly one parser result for the model it is showing.
onCurrentEditorChangedre-reads the editor and re-parses on every tab or split switch; content changes go through a 250 ms trailing debounce;onDidChangeLanguagere-parses because the rules are per language. Both the current editor and the language listener are disposed with the widget, and listeners are pushed off the model they belong to before a new one is attached so switching files cannot leak listeners into a closed editor. bashis accepted as an alias of this build'sshellscriptid, since the two spellings differ between Monaco language registrations.- Rendering is a roving-
tabindexARIAtreewithtreeitemrows carryingaria-level,aria-selectedand anaria-activedescendanton the tree.ArrowUp/ArrowDown/Home/Endmove the selection andEnterjumps to the selected row's line. A click and anEnterboth call onenavigate, which clamps the line to the model's length and usessetPosition+revealPositionInCenterIfOutsideViewport+setSelectionon the control, so the caret lands on the declaration and the status bar agrees. Sorting (document order or A-Z) and the name filter are view state over the parsed entries, never a re-parse.
Folder as Workspace panel¶
- There is no bespoke tree: the panel is Theia's File Navigator presented the
way Notepad++ presents Folder as Workspace. The side panel header reads the
hosting
ViewContainer's title, not the navigator widget's, soNotepadiaShellContributionretitles the container with idexplorer-view-containertoFolder as Workspace. Lumino'stitle.changedsignal is used rather than a one-off assignment, so a later rename is corrected and the header re-renders; the label already matching makes the second pass a no-op, so the signal cannot loop. - The container may be created on either side of this contribution's
onDidInitializeLayout, so it is retitled both fromWidgetManager.onDidCreateWidget(keyed on the container's factory id) and fromonDidInitializeLayoutviagetWidget, whichever runs last. - The tree is tightened in
style/notepadia-shell.cssbehind thebody.notepadia-chromegate (20px rows, smaller expansion toggle, 12px segments). The built-innoneicon theme is selected once on startup (workbench.iconTheme) so the names are plain, as they are in Notepad++, and only when the user has not already set the preference in any scope. NotepadiaMenuContributiontrims the navigator's context menu in place with the existingunregisterMenuActionpattern, scoped toNAVIGATOR_CONTEXT_MENU: Open, New File, New Folder, Copy Path, Rename, Delete and Remove Folder stay; Open With, Copy/Paste, Copy Relative Path, Copy Download Link, Duplicate, Compare, Download/Upload and Collapse All go. AFind in Files...entry is registered in the menu's search group and opens the shared Find dialog on its Find in Files tab.- The panel starts collapsed (
ApplicationShell.collapsePanel('left')inonDidInitializeLayout), andnotepadia.view.toggleFolderWorkspaceanswersisToggledfromshell.isExpanded('left'), so the View item and the toolbar button are one state.
Toolbar¶
NotepadiaToolbarContribution mounts the Notepad++ toolbar into the shell's
top area and NotepadiaToolbarWidget renders it as a 26px strip. Key
decisions:
- The widget is added in
onDidInitializeLayout(notonStart) so the menu bar, which is added during theonStartphase, exists first. - The declarative model lives in
notepadia-toolbar-items.ts, which is deliberately import-free so the compiled module can be loaded from the Node-side unit tests; the tests cross-check every button's command id against the registered commands and fail the build if a button ever points at a command that does not exist. - Theia 1.75 lays the top area out as a flex row, so a second widget would
sit beside the menu bar. The stylesheet layer wraps the panel instead
(
#theia-top-panel { flex-wrap: wrap }, the toolbar atflex: 0 0 100%), putting the strip onto its own row below the menu bar. - Lumino hard-sizes the top panel to its fit minimum (~the menu bar's 32px),
which no CSS can grow, so a second row would sit behind the editor area and
swallow clicks. The contribution raises the panel's box-layout size
basis (
BoxPanel.setSizeBasis, by exactly the 26px strip) and lowers it again whenView ▸ Toolbarhides the toolbar, so the editor area shifts down below the strip. - Accessibility: a single tab stop (roving tabindex,
nextFocusIndexhelpers unit-tested without a DOM), arrow +Home/Endnavigation, andaria-pressedonly on buttons whose command reports a real toggled state (theeditor.wordWrappreference gates the Word Wrap button because Theia'stoggleWordWraphandler registers noisToggled).
Automatic updates¶
The desktop app uses electron-updater fed by GitHub Releases
(publish.provider: github in electron-builder.yml).
extensions/notepadia/src/common/notepadia-updater-protocol.tsdefines the RPC path/services/notepadia/updater, theNotepadiaUpdateStatusmodel and theNotepadiaUpdaterServiceinterface (RPC, proxied over the Electron IPC).extensions/notepadia/src/electron-main/notepadia-updater.tsimplements the service in the electron-main process. It configuresautoUpdater(autoDownload: false,autoInstallOnAppQuit: true) whenapp.isPackaged; otherwise it reports adisabledstatus. AllautoUpdaterevents (checking, available, not-available, download-progress, downloaded, error) are forwarded to the connected frontend client, anddownloadUpdate()starts the download the dialog asks for.extensions/notepadia/src/browser/notepadia-updater-contribution.tsis the frontend half: it resolves the proxy fromElectronIpcConnectionProvideronly whenElectronMainConnectionProvideris bound (i.e. in the Electron app, never in the browser app), auto-checks on start, exposesHelp ▸ Check for Updates..., and opens a dialog that reports the result and, when an update exists, offers to download and then to restart and install.- The electron-main module binds the service plus an
ElectronConnectionHandler(RpcConnectionHandler) atNotepadiaUpdaterPath.
Upstream references: @theia/core ElectronMainConnectionProvider /
ElectronConnectionHandler / ElectronMainApplicationContribution, and the
RpcServer protocol from @theia/core/lib/common/messaging/proxy-factory.
Desktop packaging¶
applications/electron builds the desktop app on the Electron target
(@theia/electron backend, main: lib/backend/electron-main.js) and packages
it with electron-builder:
yarn build:prodproduces the production frontend/backend bundles; this already runstheia rebuild:electronso native addons match Electron 42.- Root helpers:
yarn package:win,yarn package:linux,yarn package:maceach cleandist, rebuild the extension and the app, then runelectron-builder --publish neverfor the given platform.yarn package:previewproduces an unpackeddist/win-unpackeddirectory for quick inspection. Publishing itself is handled by CI (see Releases & updates). - NSIS target: assisted installer (
oneClick: false) with a fixed install directory (allowToChangeInstallationDirectory: false):electron-updatercan only apply updates for the default location. - macOS builds are unsigned (
CSC_IDENTITY_AUTO_DISCOVERY=false); a signing certificate is not configured yet. - Linux metadata (category
Utility, MIME types, artifact naming) lives inelectron-builder.yml, andpackage.jsonmust carry ahomepage(RPM rejects a missing one). RPM/DEB go through electron-builder's bundledfpm(Ruby), which needslibcrypt.so.1on hosts without it. - The packaged app ships the same
notepadiaextension as the browser app (verified by listing theresources/app.asarinside the extracted AppImage); product behavior is therefore identical between targets. - Brand icons are configured for all three platforms (
build/icon.ico,build/icon.icns,build/icons/*), generated fromapplications/electron/build/icon.svgbyapplications/electron/build/generate-icons.mjs. The remaining nitpick (does not block) is that window-to-.desktopassociation is generic until a dedicateddesktopNameis configured.
Electron parity¶
The browser application is the product; the Electron target must not lag it.
The work that brought the desktop build to parity with the browser-first
feature set lives in the extension's electron-main entry point and in the
runtime branches of the shared contributions:
- Native file dialogs. The local-file commands
(
notepadia-local-files-contribution.ts) checkelectronTheiaCore.useNativeElementsat runtime: in the browser they use the File System Access API or its fallback, and in the desktop app they use the OS's native Open/Save dialogs. Native dialogs return ordinary paths, so the command writes through Theia'sFileServiceand there is no kept file handle. - Print in place.
notepadia-print-contribution.tsdetects the Electron runtime the same way and, instead of opening a separate print window (which the core'ssetWindowOpenHandlerwould intercept), injects the print body into the current page and calls the renderer's ownwindow.print(), which is the renderer's handle onwebContents.print. - Preference parity is checked.
scripts/check-preference-defaults.mjs(yarn check:prefs) parses the generated frontend/backend configs for the browser and Electron targets and fails if any package.json preference is missing from any of them. This is the guard that catches a stalesrc-genthat would otherwise silently drop the Notepad++ default behavior profile (includingsecurity.workspace.trust.enabled: false) from the desktop build. - A smoke test.
scripts/electron-smoke.mjs(yarn smoke:electron) drives the built desktop app over CDP on a throwaway workspace and asserts the editor comes up with no trust dialog and no Restricted Mode. It runs on every release leg (underxvfb-runon the headless Linux runner). - The updater has a logger. The Electron main container binds a console
logger stack in
notepadia-electron-main-module.ts, soNotepadiaUpdatercan report its checks and downloads.
Testing¶
The behavior described on this page is held in place by four layers:
- Unit tests — pure logic lives in
extensions/notepadia/src/common/*.tsand is covered bynode --testsuites inextensions/notepadia/test/*.test.cjs(wired intoyarn test): search options, encoding, EOL, status fields, the function-list rules, the toolbars' command ids, theme contrast, and more. - End-to-end suites — 41 Puppeteer suites in
e2e/drive the real browser app and are registered in theSUITESarray ofe2e/run.cjs(yarn test:e2e). They cover the editor, menus, search, panels, the browser file commands, backup/recovery, the a11y checks and more. - Visual and accessibility gates —
e2e/screenshots.cjspixel-diffs the main editor and the Find dialog against committed baselines, so a styling regression fails CI;e2e/a11y.cjsfails on serious or critical axe violations and on a trapped editor;theme-contrast.test.cjschecks every shipped theme against WCAG. - Parity and smoke checks —
yarn check:prefsguards preference parity across the generated configs,yarn smokeboots the built browser app, andyarn smoke:electronboots the packaged desktop app.
CI runs these as build.yml (environment + build), e2e.yml (lint + build +
the two e2e shards) and, on release, the smoke test on each platform leg.