├── .gitignore ├── COPYRIGHT ├── src ├── icon │ ├── icon.ico │ └── icon.png ├── window │ ├── less │ │ ├── webDialog.less │ │ ├── scrollbar.less │ │ ├── switcher.less │ │ ├── userSettings.less │ │ ├── colors.less │ │ ├── aceSearch.less │ │ ├── split.less │ │ ├── popup.less │ │ ├── window.less │ │ └── top.less │ ├── openFile.ts │ ├── ThemeMode.ts │ ├── useSVG.ts │ ├── format.ts │ ├── popups │ │ ├── updateStatusPopup.ts │ │ ├── promptUnsaved.ts │ │ ├── showAbout.ts │ │ ├── WebDialogFactory.ts │ │ ├── popup.ts │ │ ├── userSettingsData.ts │ │ ├── UserSettingsPopup.ts │ │ └── MiniPopupFactory.ts │ ├── UpdateUI.ts │ ├── HTMLClipboard.ts │ ├── userOptions.ts │ ├── MenuActionProcessor.ts │ ├── SplitElement.ts │ ├── FileSwitcher.ts │ ├── Tabs.ts │ ├── window.html │ ├── window.ts │ └── Tab.ts ├── utils │ ├── emittedOnce.ts │ ├── SettingStore.ts │ └── fileTypes.ts ├── main │ ├── convertText.ts │ ├── FileHandler.ts │ ├── handleKeyboardShortcut.ts │ ├── setUpTabTransferring.ts │ ├── Updater.ts │ ├── showTopMenu.ts │ ├── SessionManager.ts │ ├── main.ts │ ├── WindowFactory.ts │ └── contextMenu.ts ├── types.d.ts └── preload │ └── preload.ts ├── images └── screenshot.png ├── .github ├── dependabot.yml └── workflows │ ├── pr.yml │ └── release.yml ├── tsconfig.json ├── README.md ├── .eslintrc ├── webpack.config.ts ├── package.json └── LICENSE.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright (C) Brandon Fowler 2 | -------------------------------------------------------------------------------- /src/icon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/burrow/main/src/icon/icon.ico -------------------------------------------------------------------------------- /src/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/burrow/main/src/icon/icon.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/burrow/main/images/screenshot.png -------------------------------------------------------------------------------- /src/window/less/webDialog.less: -------------------------------------------------------------------------------- 1 | .prompt-input { 2 | display: block; 3 | margin-top: 1em; 4 | width: 100%; 5 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /src/utils/emittedOnce.ts: -------------------------------------------------------------------------------- 1 | export function emittedOnce(element: HTMLElement, eventName: string): Promise { 2 | return new Promise(resolve => 3 | element.addEventListener(eventName, () => resolve(true), {once: true}) 4 | ); 5 | } -------------------------------------------------------------------------------- /src/window/openFile.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import Tabs from './Tabs'; 3 | 4 | export default async function openFile(tabs: Tabs) { 5 | const files = await ipcRenderer.invoke('show-open-dialog'); 6 | 7 | (files as string[]).forEach(path => tabs.createFromFile(path)); 8 | } -------------------------------------------------------------------------------- /src/window/less/scrollbar.less: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 7px; 3 | height: 7px; 4 | } 5 | 6 | ::-webkit-scrollbar-corner, 7 | ::-webkit-scrollbar-track { 8 | background: transparent; 9 | } 10 | 11 | ::-webkit-scrollbar-thumb { 12 | background: var(--scroll-color); 13 | border-radius: 0; 14 | } -------------------------------------------------------------------------------- /src/main/convertText.ts: -------------------------------------------------------------------------------- 1 | import { Converter } from 'showdown'; 2 | 3 | let mdConverter: Converter; 4 | 5 | export default function convertText(mode: string, text: string) { 6 | if (mode === 'markdown') { 7 | if (!mdConverter) mdConverter = new Converter(); 8 | 9 | return mdConverter.makeHtml(text); 10 | } 11 | 12 | return text; 13 | } -------------------------------------------------------------------------------- /src/window/less/switcher.less: -------------------------------------------------------------------------------- 1 | .switcher-group .switcher-group .switcher-item { 2 | padding-left: 1.5em; 3 | opacity: 0.5; 4 | } 5 | 6 | .switcher-name { 7 | white-space: nowrap; 8 | overflow: hidden; 9 | text-overflow: ellipsis; 10 | padding: 0.25em 0.5em; 11 | cursor: pointer; 12 | 13 | &:hover, 14 | &:focus { 15 | background: var(--top-color); 16 | } 17 | } -------------------------------------------------------------------------------- /src/window/less/userSettings.less: -------------------------------------------------------------------------------- 1 | .setting-row { 2 | display: block; 3 | 4 | select, 5 | input { 6 | margin-left: .5em; 7 | vertical-align: middle; 8 | } 9 | 10 | input:not([type="checkbox"]) { 11 | width: 70px; 12 | } 13 | } 14 | 15 | .setting-section-heading { 16 | margin: 0; 17 | 18 | &:not(:first-child) { 19 | padding-top: 1em !important; 20 | } 21 | } -------------------------------------------------------------------------------- /src/window/ThemeMode.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export default class ThemeMode extends EventEmitter { 4 | static darkMatch = matchMedia('(prefers-color-scheme: dark)'); 5 | 6 | constructor() { 7 | super(); 8 | ThemeMode.darkMatch.addEventListener('change', e => this.emit('change', e.matches)); 9 | } 10 | 11 | get darkMode() { 12 | return ThemeMode.darkMatch.matches; 13 | } 14 | } -------------------------------------------------------------------------------- /src/window/useSVG.ts: -------------------------------------------------------------------------------- 1 | export function useSVG(id: string, className?: string) { 2 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), 3 | use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); 4 | 5 | svg.setAttributeNS(null, 'viewBox', '0 0 100 100'); 6 | svg.classList.add('icon'); 7 | if (className) svg.classList.add(className); 8 | 9 | use.setAttributeNS(null, 'href', '#' + id); 10 | svg.append(use); 11 | 12 | return svg; 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Bundler", 5 | "noImplicitAny": true, 6 | "strictNullChecks": true, 7 | "sourceMap": false, 8 | "esModuleInterop": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "allowSyntheticDefaultImports": true, 12 | "target": "ESNext", 13 | "resolveJsonModule": true 14 | }, 15 | "ts-node": { 16 | "compilerOptions": { 17 | "module": "CommonJS" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.ico' 2 | declare module '*.md' 3 | declare module '*.png' 4 | 5 | declare const enum SaveType { 6 | SetName, 7 | Standard, 8 | Auto 9 | } 10 | 11 | declare interface TabData { 12 | mode?: string; 13 | path?: string; 14 | text?: string; 15 | savedText?: string; 16 | } 17 | 18 | declare interface UpdateStatus { 19 | state: 'checking' | 'unavailable' | 'available' | 'downloading' | 'downloaded' | 'error'; 20 | title: string; 21 | details: string; 22 | } -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Test Pull Request 2 | 3 | on: 4 | pull_request 5 | 6 | jobs: 7 | pr: 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | matrix: 12 | os: 13 | - macos-latest 14 | - ubuntu-latest 15 | - windows-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v1 20 | 21 | - name: Install node 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: 20 25 | 26 | - name: Install dependencies 27 | run: npm install 28 | 29 | - name: Lint 30 | run: npm run lint 31 | 32 | - name: Build 33 | run: npm run build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Burrow HTML and Markdown 2 | 3 | 4 | 5 | Burrow is an HTML, markdown, and SVG viewer, code editor, and debugger built with Electron. It allows you to easily burrow into markup files by providing a quick way to switch between viewing, editing, and debugging files. 6 | 7 | ## Screenshot 8 | 9 | 10 | 11 | ## Developing 12 | 13 | Before build commands can be run `npm` must be installed and dependencies must be installed with `npm install`. 14 | 15 | To create and run a development distribution run `npm start` and to create a production build run `npm run build`. To lint your code for issues, run `npm run lint`. 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "tsconfig.json" 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:deprecation/recommended" 15 | ], 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": [ 19 | "error", 20 | { 21 | "vars": "all", 22 | "varsIgnorePattern": "^_", 23 | "args": "all", 24 | "argsIgnorePattern": "^_", 25 | "caughtErrors": "all", 26 | "caughtErrorsIgnorePattern": "^_" 27 | } 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/window/less/colors.less: -------------------------------------------------------------------------------- 1 | :root { 2 | --transparent: rgba(0, 0, 0, 0.25); 3 | } 4 | 5 | @media (prefers-color-scheme: light) { 6 | :root { 7 | --bg-color: #fff; 8 | --top-color: #f1f1f1; 9 | --border-color: #d0d0d0; 10 | --txt-color: #444; 11 | --scroll-color: #d0d0d0; 12 | --close-color: #E81123; 13 | --button-color: #e3e3e3; 14 | --button-hover-color: #d8d8d8; 15 | --link-color: #1d2ec9; 16 | --update-color: #a9f1a9; 17 | } 18 | } 19 | 20 | @media (prefers-color-scheme: dark) { 21 | :root { 22 | --bg-color: #222; 23 | --top-color: #444; 24 | --border-color: #525252; 25 | --txt-color: #ccc; 26 | --scroll-color: #525252; 27 | --close-color: #c40c0c; 28 | --button-color: #333; 29 | --button-hover-color: #383838; 30 | --link-color: #75acff; 31 | --update-color: #405b40; 32 | } 33 | } -------------------------------------------------------------------------------- /src/window/less/aceSearch.less: -------------------------------------------------------------------------------- 1 | #editor-container { 2 | .ace_search { 3 | color: var(--txt-color); 4 | background: var(--top-color); 5 | border-color: var(--border-color); 6 | border-radius: 0; 7 | } 8 | 9 | .ace_button { 10 | color: var(--txt-color); 11 | border-color: var(--border-color); 12 | 13 | &:active, 14 | &:hover { 15 | background-color: var(--button-hover-color); 16 | } 17 | } 18 | 19 | .ace_search_field, 20 | .ace_searchbtn { 21 | color: var(--txt-color); 22 | background-color: var(--button-color); 23 | border-color: var(--border-color); 24 | } 25 | } 26 | 27 | 28 | @media (prefers-color-scheme: dark) { 29 | #editor-container { 30 | .ace_searchbtn_close { 31 | filter: brightness(1.5); 32 | background-color: var(--button-color); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/window/format.ts: -------------------------------------------------------------------------------- 1 | import { Ace } from "ace-builds"; 2 | import SettingStore from "src/utils/SettingStore"; 3 | import prettier from 'prettier/standalone'; 4 | import htmlPlugin from 'prettier/plugins/html'; 5 | import mdPlugin from 'prettier/plugins/markdown'; 6 | import xmlPlugin from '@prettier/plugin-xml'; 7 | import babelPlugin from 'prettier/plugins/babel'; 8 | import estreePlugin from 'prettier/plugins/estree'; 9 | import cssPlugin from 'prettier/plugins/postcss'; 10 | 11 | export default async function format(editor: Ace.Editor, mode: string, settings: SettingStore) { 12 | const code = editor.getValue(), 13 | parser = mode === 'svg' ? 'xml' : mode, 14 | formatted = await prettier.format(code, { 15 | parser, 16 | plugins: [htmlPlugin, xmlPlugin, mdPlugin, babelPlugin, estreePlugin, cssPlugin], 17 | useTabs: !settings.get('softTabs'), 18 | tabWidth: settings.get('tabSize'), 19 | embeddedLanguageFormatting: 'auto' 20 | }); 21 | 22 | editor.setValue(formatted, 1); 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | 13 | runs-on: ${{ matrix.os }} 14 | continue-on-error: true 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - macos-latest 20 | - ubuntu-latest 21 | - windows-latest 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v1 26 | 27 | - name: Install node 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 20 31 | 32 | - name: Install Snapcraft 33 | uses: samuelmeuli/action-snapcraft@v2 34 | if: startsWith(matrix.os, 'ubuntu') 35 | 36 | - name: Install dependencies 37 | run: npm install 38 | 39 | - name: Lint 40 | run: npm run lint 41 | 42 | - name: Deploy app 43 | env: 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} 46 | run: npm run release -------------------------------------------------------------------------------- /src/window/popups/updateStatusPopup.ts: -------------------------------------------------------------------------------- 1 | import { IpcRendererEvent, ipcRenderer } from 'electron'; 2 | import { popup } from './popup'; 3 | 4 | export default function updateStatusPopup(init: true): void; 5 | export default function updateStatusPopup(init: false, status: UpdateStatus): void; 6 | export default function updateStatusPopup(init: boolean, status?: UpdateStatus): void { 7 | if (init) ipcRenderer.send('check-for-updates'); 8 | 9 | const title = new Text(init ? 'Initializing...' : status!.title), 10 | details = new Text(init ? 'Updater initializing...' : status!.details); 11 | 12 | const updateStateListener = (_: IpcRendererEvent, status: UpdateStatus) => { 13 | title.textContent = status.title; 14 | details.textContent = status.details; 15 | }; 16 | 17 | popup( 18 | title, 19 | details, 20 | [{ text: init ? 'Close' : 'OK' }], 21 | undefined, 22 | false, 23 | () => ipcRenderer.off('update-status', updateStateListener) 24 | ); 25 | 26 | ipcRenderer.on('update-status', updateStateListener); 27 | } -------------------------------------------------------------------------------- /src/preload/preload.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, contextBridge, webFrame } from 'electron'; 2 | 3 | const prefix = `BURROW_${(Math.random() + 1).toString(36).substring(2)}`; 4 | 5 | function genFuncOverrides(...funcs: string[]) { 6 | return funcs.map(func => `window.${func}=window.${prefix}_${func};`).join(''); 7 | } 8 | 9 | contextBridge.exposeInMainWorld(`${prefix}_alert`, (message: unknown) => { 10 | ipcRenderer.sendSync('web-dialog', 'alert', message); 11 | }); 12 | 13 | contextBridge.exposeInMainWorld(`${prefix}_confirm`, (message: unknown) => { 14 | return ipcRenderer.sendSync('web-dialog', 'confirm', message); 15 | }); 16 | 17 | contextBridge.exposeInMainWorld(`${prefix}_prompt`, (message: unknown, initial: unknown) => { 18 | return ipcRenderer.sendSync('web-dialog', 'prompt', message, initial); 19 | }); 20 | 21 | window.addEventListener('keyup', e => { 22 | if (e.defaultPrevented) return; 23 | 24 | ipcRenderer.send( 25 | 'keyboard-input', 26 | false, 27 | e.key, 28 | process.platform === 'darwin' ? e.metaKey : e.ctrlKey, 29 | e.altKey, 30 | e.shiftKey 31 | ); 32 | }); 33 | 34 | webFrame.executeJavaScript(genFuncOverrides('alert', 'confirm', 'prompt')); -------------------------------------------------------------------------------- /src/window/UpdateUI.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import updateStatusPopup from './popups/updateStatusPopup'; 3 | 4 | export default class UpdateUI { 5 | static IGNORE_STATES: UpdateStatus['state'][] = ['checking', 'unavailable', 'error']; 6 | 7 | updateEl = document.getElementById('update')! as HTMLButtonElement; 8 | status?: UpdateStatus; 9 | 10 | init() { 11 | ipcRenderer.on('update-status', (_, status: UpdateStatus) => this.applyStatus(status)); 12 | ipcRenderer.invoke('get-update-status').then((status?: UpdateStatus) => this.applyStatus(status)); 13 | 14 | this.updateEl.addEventListener('click', () => { 15 | if (!this.status) return; 16 | 17 | updateStatusPopup(false, this.status); 18 | }); 19 | } 20 | 21 | applyStatus(newStatus: UpdateStatus | undefined) { 22 | this.status = newStatus?.state && !UpdateUI.IGNORE_STATES.includes(newStatus.state) 23 | ? newStatus : undefined; 24 | 25 | this.updateEl.style.display = this.status ? '' : 'none'; 26 | this.updateEl.title = this.status?.title ?? ''; 27 | this.updateEl.dataset.updateState = this.status?.state ?? ''; 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/FileHandler.ts: -------------------------------------------------------------------------------- 1 | import { App, BrowserWindow } from 'electron'; 2 | 3 | export default class FileHandler { 4 | currentWindow: BrowserWindow | undefined; 5 | fileQueue: string[] | undefined = []; 6 | 7 | openFiles(...files: string[]) { 8 | if (!this.currentWindow) { 9 | this.fileQueue!.push(...files); 10 | return; 11 | } 12 | 13 | this.currentWindow.show(); 14 | this.currentWindow.webContents.send('open-files', files); 15 | } 16 | 17 | registerEvents(app: App) { 18 | app.on('second-instance', (_, _args, _cwd, files: string[]) => this.openFiles(...files)); 19 | app.on('open-file', (_, path) => this.openFiles(path)); 20 | 21 | } 22 | 23 | consumeQueue() { 24 | if (!this.fileQueue?.length) return; 25 | 26 | const consumedQueue = this.fileQueue; 27 | this.fileQueue = []; 28 | 29 | return consumedQueue; 30 | } 31 | 32 | setCurrentWindow(win: BrowserWindow) { 33 | this.currentWindow = win; 34 | win.on('focus', () => this.currentWindow = win); 35 | 36 | if (this.fileQueue?.length) { 37 | win.webContents.send('open-files', this.fileQueue); 38 | } 39 | 40 | this.fileQueue = undefined; 41 | } 42 | } -------------------------------------------------------------------------------- /src/window/HTMLClipboard.ts: -------------------------------------------------------------------------------- 1 | import { Ace } from "ace-builds"; 2 | 3 | export default class HTMLClipboard { 4 | constructor(public editor: Ace.Editor) { } 5 | 6 | private get range() { 7 | return this.editor.selection.getRange(); 8 | } 9 | 10 | async cut() { 11 | await this.copy(); 12 | this.editor.getSession().replace(this.range, ''); 13 | } 14 | 15 | async copy() { 16 | const html = this.editor.getSession().doc.getTextRange(this.range), 17 | frame = document.createElement('iframe'); 18 | 19 | frame.src = 'about:blank'; 20 | document.body.appendChild(frame); 21 | 22 | const doc = frame.contentDocument!, 23 | div = doc.createElement('div'); 24 | 25 | div.innerHTML = html; 26 | doc.body.appendChild(div); 27 | 28 | const text = div.innerText; 29 | 30 | div.remove(); 31 | frame.remove(); 32 | 33 | const item = new ClipboardItem({ 34 | 'text/html': new Blob([html], {type: 'text/html'}), 35 | 'text/plain': new Blob([text], {type: 'text/plain'}), 36 | }); 37 | 38 | await navigator.clipboard.write([item]); 39 | } 40 | 41 | async paste() { 42 | const items = await navigator.clipboard.read(); 43 | 44 | for (const item of items) { 45 | if (!item.types.includes('text/html')) continue; 46 | 47 | const blob = await item.getType('text/html'); 48 | this.editor.getSession().replace(this.range, await blob.text()); 49 | 50 | return; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/window/popups/promptUnsaved.ts: -------------------------------------------------------------------------------- 1 | import { popup } from './popup'; 2 | import SettingStore from '../../utils/SettingStore'; 3 | import Tabs from '../Tabs'; 4 | import Tab from '../Tab'; 5 | 6 | export default async function promptUnsaved(tabOrTabs: Tabs | Tab, settings: SettingStore): Promise { 7 | const tabsToClose = tabOrTabs instanceof Tabs ? tabOrTabs.tabs : [tabOrTabs]; 8 | 9 | let unsaved = tabsToClose.filter(tab => tab.unsaved); 10 | 11 | if (unsaved.length && settings.get('autoSave')) { 12 | await Promise.allSettled(unsaved.map(tab => tab.save(SaveType.Auto))); 13 | unsaved = tabsToClose.filter(tab => tab.unsaved); 14 | } 15 | 16 | if (!unsaved.length) return true; 17 | 18 | return new Promise(resolve => { 19 | const save = async () => { 20 | const results = await Promise.all(unsaved.map(tab => tab.save())); 21 | resolve(results.every(saved => saved)); 22 | }; 23 | 24 | popup( 25 | 'Unsaved changes!', 26 | unsaved.length > 1 27 | ? 'You have unsaved tabs, would you like to save them now?' 28 | : 'Tab has unsaved changes, would you like to save them now?', 29 | [ 30 | { 31 | text: unsaved.length > 1 ? 'Save All' : 'Save', 32 | click: save 33 | }, 34 | { 35 | text: 'Don\'t Save', 36 | click: () => resolve(true) 37 | }, 38 | { 39 | text: 'Cancel' 40 | } 41 | ], 42 | undefined, 43 | false, 44 | () => resolve(false), 45 | save 46 | ); 47 | }); 48 | } -------------------------------------------------------------------------------- /src/main/handleKeyboardShortcut.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron'; 2 | 3 | const CTRLORCMD = 1; 4 | const ALT = 2; 5 | const SHIFT = 4; 6 | 7 | const shortcuts: [string, number, string, string?][] = [ 8 | ['D', CTRLORCMD, 'toggle-devtools'], 9 | ['D', CTRLORCMD | SHIFT, 'rotate-devtools'], 10 | ['E', CTRLORCMD, 'toggle-editor'], 11 | ['E', CTRLORCMD | SHIFT, 'rotate-editor'], 12 | ['F', CTRLORCMD, 'find', 'NONE'], 13 | ['N', CTRLORCMD, 'new'], 14 | ['O', CTRLORCMD, 'open'], 15 | ['P', CTRLORCMD, 'print'], 16 | ['R', CTRLORCMD, 'run'], 17 | ['S', CTRLORCMD, 'save'], 18 | ['S', CTRLORCMD | SHIFT, 'save-as'], 19 | ['W', CTRLORCMD, 'close'], 20 | ['=', CTRLORCMD, 'zoom', 'NONE'], 21 | ['Tab', CTRLORCMD, 'prev-tab'], 22 | ['Tab', CTRLORCMD | SHIFT, 'next-tab'], 23 | ['ArrowLeft', ALT, 'back'], 24 | ['ArrowRight', ALT, 'forward'], 25 | ['Escape', 0, 'close-mini', 'NONE'] 26 | ]; 27 | 28 | export default function handleKeyboardShortcut( 29 | _: Electron.IpcMainEvent, 30 | editor: boolean, 31 | key: string, 32 | ctrlOrCmd: boolean, 33 | alt: boolean, 34 | shift: boolean 35 | ) { 36 | const flag = Number(ctrlOrCmd) * CTRLORCMD + Number(alt) * ALT + Number(shift) * SHIFT; 37 | 38 | shortcuts.some(([shortcutKey, shortcutFlag, action, editorAction]) => { 39 | if (shortcutKey.toUpperCase() !== key.toUpperCase() || (shortcutFlag && shortcutFlag !== flag)) return; 40 | 41 | BrowserWindow.getFocusedWindow()?.webContents.send('menu-action', (editor && editorAction) ? editorAction : action); 42 | 43 | return true; 44 | }); 45 | } -------------------------------------------------------------------------------- /src/utils/SettingStore.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | 3 | export default class SettingStore { 4 | readonly defaults: Record = { 5 | // User settings 6 | autoRun: true, 7 | autoSave: true, 8 | theme: 'system', 9 | viewerUseTheme: true, 10 | autoEdit: false, 11 | autoDevtools: false, 12 | autoSwitcher: false, 13 | softTabs: false, 14 | tabSize: 4, 15 | showInvisible: false, 16 | gutter: true, 17 | wordWrap: true, 18 | defaultType: 'html', 19 | autocomplete: true, 20 | enableSnippets: true, 21 | 22 | // Layout settings 23 | editorDirection: 'horizontal', 24 | devtoolsDirection: 'vertical', 25 | editorWidth: '50%', 26 | editorHeight: '50%', 27 | viewerWidth: '50%', 28 | viewerHeight: '50%', 29 | switcherWidth: '175px' 30 | }; 31 | 32 | constructor( 33 | public onSet: () => void, 34 | public store = new Store() 35 | ) { } 36 | 37 | get(name: string) { 38 | return this.store.get(name, this.defaults[name]) as T; 39 | } 40 | 41 | set(name: string, value: T): void { 42 | this.store.set(name, value); 43 | this.onSet(); 44 | } 45 | 46 | listen(name: string, callback: (newValue: T) => void): () => void { 47 | return this.store.onDidChange(name, callback); 48 | } 49 | 50 | callAndListen(name: string, callback: (newValue: T) => void): () => void { 51 | callback(this.get(name)); 52 | return this.store.onDidChange(name, callback); 53 | } 54 | 55 | markExternalSet() { 56 | this.store.events.dispatchEvent(new Event('change')); 57 | } 58 | } -------------------------------------------------------------------------------- /src/utils/fileTypes.ts: -------------------------------------------------------------------------------- 1 | import { FileFilter } from 'electron'; 2 | import { extname } from 'path'; 3 | 4 | export const fileTypes = [ 5 | { 6 | type: 'html', 7 | name: 'HTML', 8 | extensions: ['html', 'htm'] 9 | }, 10 | { 11 | type: 'svg', 12 | name: 'SVG', 13 | extensions: ['svg'] 14 | }, 15 | { 16 | type: 'markdown', 17 | name: 'Markdown', 18 | shortName: 'MD', 19 | extensions: ['md', 'markdown'] 20 | } 21 | ]; 22 | 23 | const defaultExtensionMap: Record = {}; 24 | const fileTypeMap: Record = {}; 25 | 26 | for (const typeInfo of fileTypes) { 27 | defaultExtensionMap[typeInfo.type] = typeInfo.extensions[0]; 28 | 29 | for (const extension of typeInfo.extensions) { 30 | fileTypeMap[extension] = typeInfo.type; 31 | } 32 | } 33 | 34 | export function getDefaultExtension(fileType: string): string | undefined { 35 | return defaultExtensionMap[fileType]; 36 | } 37 | 38 | export function getFileType(path: string): string | undefined { 39 | return fileTypeMap[extname(path).substring(1)]; 40 | } 41 | 42 | export function getSaveFilters(first?: string): FileFilter[] { 43 | const saveFilters = fileTypes.map(fileType => ({ 44 | name: fileType.name, 45 | extensions: fileType.extensions 46 | })); 47 | 48 | if (first) { 49 | const index = fileTypes.findIndex(x => x.type === first); 50 | 51 | saveFilters.unshift(saveFilters.splice(index, 1)[0]); 52 | } 53 | 54 | return saveFilters; 55 | } 56 | 57 | export function getOpenFilters(): FileFilter[] { 58 | return [ 59 | { 60 | name: 'All Supported Files', 61 | extensions: Object.values(fileTypes).map(fileType => fileType.extensions).flat() 62 | }, 63 | ...getSaveFilters() 64 | ] 65 | } -------------------------------------------------------------------------------- /src/window/less/split.less: -------------------------------------------------------------------------------- 1 | .split { 2 | display: flex; 3 | position: relative; 4 | 5 | .active-resize() { 6 | position: absolute; 7 | top: 0 !important; 8 | left: 0 !important; 9 | right: 0 !important; 10 | bottom: 0 !important; 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | > .resize { 16 | position: absolute; 17 | user-select: none; 18 | z-index: 11; 19 | } 20 | 21 | > :last-child { 22 | flex: 1; 23 | } 24 | 25 | &.vertical:not(.hidden) { 26 | flex-direction: column; 27 | 28 | > :first-child { 29 | width: 100% !important; 30 | height: 50%; 31 | border-bottom: 1px solid var(--border-color); 32 | } 33 | 34 | > .resize { 35 | cursor: ns-resize; 36 | width: 100%; 37 | height: 6px; 38 | top: 50%; 39 | left: 0 !important; 40 | margin: -3px 0; 41 | 42 | &.active { 43 | .active-resize(); 44 | } 45 | } 46 | } 47 | 48 | &.horizontal:not(.hidden) { 49 | > :first-child { 50 | width: 50%; 51 | height: 100% !important; 52 | border-right: 1px solid var(--border-color); 53 | } 54 | 55 | > .resize { 56 | cursor: ew-resize; 57 | width: 6px; 58 | height: 100%; 59 | left: 50%; 60 | top: 0 !important; 61 | margin: 0 -3px; 62 | 63 | &.active { 64 | .active-resize(); 65 | } 66 | } 67 | } 68 | 69 | &.hidden { 70 | > .hide, > .resize { 71 | display: none !important; 72 | } 73 | 74 | > * { 75 | width: 100% !important; 76 | height: 100% !important; 77 | } 78 | } 79 | 80 | > :first-child, > :last-child { 81 | min-width: min(10vw, 50px); 82 | min-height: min(10vh, 50px); 83 | } 84 | } -------------------------------------------------------------------------------- /src/window/popups/showAbout.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, shell } from 'electron'; 2 | import npmPackage from '../../../package.json'; 3 | import { popup } from './popup'; 4 | import Icon from '../../icon/icon.png'; 5 | import updateStatusPopup from './updateStatusPopup'; 6 | 7 | export default function showAbout() { 8 | // eslint-disable-next-line prefer-const 9 | let closePopup: () => void; 10 | const rows: HTMLDivElement[] = []; 11 | 12 | const addRow = (...items: (string | Node)[]) => { 13 | const row = document.createElement('div'); 14 | 15 | row.className = 'row'; 16 | row.append(...items); 17 | rows.push(row); 18 | 19 | return row; 20 | }; 21 | 22 | const addActionLink = (text: string, click: () => unknown) => { 23 | const link = document.createElement('a'); 24 | link.innerText = text; 25 | link.href = '#'; 26 | 27 | link.addEventListener('click', e => { 28 | e.preventDefault(); 29 | click(); 30 | closePopup?.(); 31 | }); 32 | 33 | addRow(link); 34 | }; 35 | 36 | const icon = document.createElement('img'); 37 | icon.src = Icon; 38 | icon.style.height = '80px'; 39 | 40 | addRow(icon); 41 | addRow(npmPackage.build.productName); 42 | 43 | addRow(`Copyright \u00A9 Brandon Fowler`); 44 | addRow(`Version ${npmPackage.version}`); 45 | addRow(`Chrome ${process.versions.chrome}`); 46 | 47 | addRow(''); 48 | 49 | addActionLink('Check for updates', () => updateStatusPopup(true)); 50 | addActionLink( 51 | 'Open code repository', 52 | () => shell.openExternal('https://github.com/BrandonXLF/burrow') 53 | ); 54 | addActionLink('View license', () => ipcRenderer.send('show-license')); 55 | addActionLink('Show app devtools', () => ipcRenderer.send('show-window-devtools')); 56 | 57 | closePopup = popup('About', rows); 58 | } -------------------------------------------------------------------------------- /src/window/less/popup.less: -------------------------------------------------------------------------------- 1 | .popup-container { 2 | position: absolute; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | background: var(--transparent); 11 | z-index: 12; 12 | 13 | &.partial { 14 | z-index: 10; 15 | } 16 | 17 | &:focus { 18 | outline: none; 19 | } 20 | } 21 | 22 | .popup { 23 | background: var(--top-color); 24 | padding: 1em; 25 | margin: 1em; 26 | max-height: calc(100% - 4em); 27 | max-width: calc(100% - 4em); 28 | overflow: auto; 29 | border: 1px solid var(--border-color); 30 | 31 | &:focus { 32 | outline: none; 33 | } 34 | 35 | .popup-buttons { 36 | button { 37 | padding: 0.5em 1em; 38 | cursor: pointer; 39 | } 40 | } 41 | 42 | .row { 43 | padding-bottom: 0.25em; 44 | 45 | &:not(:first-child) { 46 | padding-top: 0.25em; 47 | } 48 | } 49 | 50 | &:not(.mini-popup) { 51 | min-width: min(250px, 33%, 100% - 4em); 52 | 53 | > :not(:first-child) { 54 | padding-top: 1em; 55 | } 56 | 57 | .popup-buttons { 58 | display: flex; 59 | flex-wrap: wrap; 60 | gap: 0.5em 1em; 61 | justify-content: end; 62 | } 63 | } 64 | 65 | &.mini-popup { 66 | position: absolute; 67 | top: 0; 68 | right: 0; 69 | border-width: 0 0 1px 1px; 70 | padding: 4px; 71 | margin: 0; 72 | max-width: calc(100% - 16px); 73 | 74 | > * { 75 | display: inline-block; 76 | } 77 | 78 | .popup-buttons { 79 | button { 80 | border: 0; 81 | background: none; 82 | margin-left: 4px; 83 | padding: 4px; 84 | 85 | &:hover { 86 | background: var(--button-hover-color); 87 | } 88 | } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/main/setUpTabTransferring.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tab transferring flow 3 | * ------------------------------------------------------------------- 4 | * | Requesting renderer | Main | Renderer with tab | 5 | * ------------------------------------------------------------------- 6 | * | request-tab --> | | 7 | * | OR new-window-with-tab --> | | 8 | * | | release-tab-to --> | 9 | * | | <-- send-tab-to | 10 | * | <-- show-tab | | 11 | * ------------------------------------------------------------------- 12 | */ 13 | 14 | import { ipcMain, webContents } from 'electron'; 15 | import WindowFactory from './WindowFactory'; 16 | 17 | function requestTabRelease(tabId: string, targetId: number, targetIndex?: number) { 18 | const sourceContentsId = parseInt(tabId.split('-')[0]); 19 | webContents.fromId(sourceContentsId)?.send('release-tab-to', tabId, targetId, targetIndex); 20 | } 21 | 22 | export default function setUpTabTransferring(windowFactory: WindowFactory) { 23 | ipcMain.on('request-tab', 24 | (e, tabId: string, targetIndex?: number) => requestTabRelease(tabId, e.sender.id, targetIndex) 25 | ); 26 | 27 | ipcMain.on('new-window-with-tab', (_, tabId: string, x: number, y: number) => { 28 | const target = windowFactory.create(undefined, { x, y }).webContents; 29 | target.once('ipc-message', () => requestTabRelease(tabId, target.id)); 30 | }); 31 | 32 | ipcMain.on('send-tab-to', (_, targetId: number, tabData: TabData, targetIndex?: number) => 33 | webContents.fromId(targetId)?.send('show-tab', tabData, targetIndex) 34 | ); 35 | } -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import type { Configuration } from 'webpack'; 2 | import path from 'path'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 5 | 6 | const commonConfig = { 7 | output: { 8 | path: path.resolve(__dirname, 'build'), 9 | filename: '[name].js' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.ts$/, 15 | use: 'ts-loader', 16 | exclude: /node_modules/, 17 | }, 18 | { 19 | test: /\.(ico|md|png)$/, 20 | use: [ 21 | { 22 | loader: 'file-loader', 23 | options: { 24 | name: '[name].[ext]' 25 | } 26 | } 27 | ] 28 | } 29 | ] 30 | }, 31 | resolve: { 32 | extensions: ['.ts', '.js'], 33 | } 34 | }; 35 | 36 | const mainConfig: Configuration = { 37 | ...commonConfig, 38 | target: 'electron-main', 39 | entry: { 40 | main: './src/main/main.ts' 41 | } 42 | }; 43 | 44 | const rendererConfig: Configuration = { 45 | ...commonConfig, 46 | target: 'electron-renderer', 47 | entry: { 48 | window: './src/window/window.ts' 49 | }, 50 | plugins: [ 51 | new HtmlWebpackPlugin({ 52 | filename: 'window.html', 53 | template: './src/window/window.html' 54 | }), 55 | new MiniCssExtractPlugin({ 56 | filename: 'window.css' 57 | }) 58 | ], 59 | module: { 60 | rules: [ 61 | ...commonConfig.module.rules, 62 | { 63 | test: /\.html$/, 64 | use: 'html-loader' 65 | }, 66 | { 67 | test: /\.css$/, 68 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 69 | }, 70 | { 71 | test: /\.less$/i, 72 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'], 73 | } 74 | ] 75 | } 76 | }; 77 | 78 | const preloadConfig: Configuration = { 79 | ...commonConfig, 80 | target: 'electron-preload', 81 | entry: { 82 | preload: './src/preload/preload.ts' 83 | } 84 | }; 85 | 86 | export default [mainConfig, rendererConfig, preloadConfig]; -------------------------------------------------------------------------------- /src/window/less/window.less: -------------------------------------------------------------------------------- 1 | @import 'colors.less'; 2 | @import 'top.less'; 3 | @import 'scrollbar.less'; 4 | @import 'aceSearch.less'; 5 | 6 | * { 7 | box-sizing: border-box; 8 | } 9 | 10 | html, 11 | body { 12 | width: 100%; 13 | height: 100%; 14 | margin: 0; 15 | overflow: hidden; 16 | } 17 | 18 | body { 19 | color: var(--txt-color); 20 | background: var(--bg-color); 21 | display: flex; 22 | flex-direction: column; 23 | font-family: sans-serif; 24 | } 25 | 26 | button, 27 | select, 28 | input { 29 | background: var(--button-color); 30 | color: var(--txt-color); 31 | border: 1px solid var(--border-color); 32 | padding: 3px; 33 | } 34 | 35 | a { 36 | color: var(--link-color); 37 | } 38 | 39 | button:not([disabled]):hover { 40 | background: var(--button-hover-color); 41 | } 42 | 43 | .icon { 44 | width: 10px; 45 | } 46 | 47 | #main { 48 | flex: 1; 49 | position: relative; 50 | } 51 | 52 | #switcher-container { 53 | position: absolute; 54 | top: 0; 55 | bottom: 0; 56 | left: 0; 57 | right: 0; 58 | } 59 | 60 | #switcher { 61 | overflow: auto; 62 | } 63 | 64 | [data-no-path] #switcher, 65 | [data-no-path] #switcher-separator { 66 | display: none; 67 | } 68 | 69 | #webview-container { 70 | display: flex; 71 | flex-direction: column; 72 | position: relative; 73 | background-color: white; 74 | 75 | webview { 76 | flex: 1; 77 | } 78 | } 79 | 80 | @media (prefers-color-scheme: dark) { 81 | #webview-container { 82 | &.use-theme { 83 | // Mimic default webview background color while loading 84 | background-color: #121212; 85 | } 86 | } 87 | } 88 | 89 | .webview-sub-container { 90 | display: flex; 91 | flex: 1; 92 | } 93 | 94 | .show-when-current:not(.current) { 95 | display: none; 96 | } 97 | 98 | #devtool-container { 99 | display: flex; 100 | 101 | webview { 102 | flex: 1; 103 | } 104 | } -------------------------------------------------------------------------------- /src/main/Updater.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from 'electron'; 2 | import { autoUpdater } from 'electron-updater'; 3 | import npmPackage from '../../package.json'; 4 | 5 | export default class Updater { 6 | status?: UpdateStatus; 7 | 8 | constructor() { 9 | autoUpdater.on('checking-for-update', () => this.updateStatus({ 10 | state: 'checking', 11 | title: 'Checking for updates...', 12 | details: 'Checking for updates from the release server.' 13 | })); 14 | 15 | autoUpdater.on('update-not-available', () => this.updateStatus({ 16 | state: 'unavailable', 17 | title: 'No updates available.', 18 | details: 'Already up to date!' 19 | })); 20 | 21 | autoUpdater.on('update-available', info => this.updateStatus({ 22 | state: 'available', 23 | title: 'Update available!', 24 | details: `Update to v${info.version} is available.` 25 | })); 26 | 27 | autoUpdater.on('download-progress', info => this.updateStatus({ 28 | state: 'downloading', 29 | title: 'Update downloading...', 30 | details: `Update is ${Math.round(info.percent)}% downloaded.` 31 | })); 32 | 33 | autoUpdater.on('update-downloaded', () => this.updateStatus({ 34 | state: 'downloaded', 35 | title: 'Update downloaded!', 36 | details: `Relaunch to use the new version of ${npmPackage.build.productName}!` 37 | })); 38 | 39 | autoUpdater.on('error', info => this.updateStatus({ 40 | state: 'error', 41 | title: 'Error while updating.', 42 | details: info.message 43 | })); 44 | 45 | ipcMain.handle('get-update-status', () => this.status); 46 | ipcMain.on('check-for-updates', () => autoUpdater.checkForUpdates()); 47 | } 48 | 49 | updateStatus(newStatus: UpdateStatus) { 50 | this.status = newStatus; 51 | 52 | BrowserWindow.getAllWindows().forEach( 53 | win => win.webContents.send('update-status', this.status) 54 | ); 55 | } 56 | 57 | check() { 58 | autoUpdater.checkForUpdates(); 59 | } 60 | } -------------------------------------------------------------------------------- /src/main/showTopMenu.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from 'electron'; 2 | import { fileTypes } from '../utils/fileTypes'; 3 | 4 | const menus: Record = { 8 | options: [ 9 | { 10 | label: 'Print...', 11 | accelerator: 'CmdOrCtrl+P', 12 | id: 'print' 13 | }, 14 | { 15 | label: 'Find...', 16 | accelerator: 'CmdOrCtrl+F', 17 | id: 'find' 18 | }, 19 | { 20 | label: 'Zoom...', 21 | accelerator: 'CmdOrCtrl+=', 22 | id: 'zoom' 23 | }, 24 | { 25 | label: 'Terminate', 26 | id: 'terminate' 27 | }, 28 | { 29 | type: 'separator' 30 | 31 | }, 32 | { 33 | label: 'Format Code', 34 | id: 'format' 35 | }, 36 | { 37 | type: 'separator' 38 | }, 39 | { 40 | label: 'Rotate Editor', 41 | accelerator: 'CmdOrCtrl+Shift+E', 42 | id: 'rotate-editor' 43 | }, 44 | { 45 | label: 'Rotate Devtools', 46 | accelerator: 'CmdOrCtrl+Shift+D', 47 | id: 'rotate-devtools' 48 | }, 49 | { 50 | type: 'separator' 51 | }, 52 | { 53 | label: 'Save', 54 | accelerator: 'CmdOrCtrl+S', 55 | id: 'save' 56 | }, 57 | { 58 | label: 'Save As...', 59 | accelerator: 'CmdOrCtrl+Shift+S', 60 | id: 'save-as' 61 | }, 62 | { 63 | type: 'separator' 64 | }, 65 | { 66 | label: 'Settings', 67 | id: 'settings' 68 | }, 69 | { 70 | label: 'About', 71 | id: 'about' 72 | } 73 | ], 74 | new: [ 75 | { 76 | label: 'Open File', 77 | accelerator: 'CmdOrCtrl+O', 78 | id: 'open' 79 | }, 80 | { 81 | type: 'separator' 82 | }, 83 | ...fileTypes.map(typeInfo => ({ 84 | label: `New ${typeInfo.shortName ?? typeInfo.name}`, 85 | accelerator: 'CmdOrCtrl+N', 86 | id: 'new', 87 | mode: typeInfo.type 88 | })) 89 | ] 90 | }; 91 | 92 | export default function showTopMenu(e: Electron.IpcMainEvent, type: 'options' | 'new', x: number, y: number, mode: string) { 93 | const template = menus[type].map(item => { 94 | const newItem = {...item}; 95 | 96 | if (newItem.id) { 97 | newItem.click = () => { 98 | e.sender.send('menu-action', newItem.id, newItem.mode); 99 | }; 100 | } 101 | 102 | if (newItem.mode && newItem.mode !== mode) { 103 | delete newItem.accelerator; 104 | } 105 | 106 | return newItem; 107 | }); 108 | 109 | Menu.buildFromTemplate(template).popup({ x, y }); 110 | } -------------------------------------------------------------------------------- /src/window/popups/WebDialogFactory.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import Tabs from '../Tabs'; 3 | import { popup, PopupButton } from './popup'; 4 | import '../less/webDialog.less' 5 | 6 | export default class WebDialogFactory { 7 | constructor(private tabs: Tabs) { } 8 | 9 | sendResponse(uuid: string, data: unknown) { 10 | ipcRenderer.send('web-dialog-response', uuid, data); 11 | } 12 | 13 | showDialog( 14 | uuid: string, 15 | title: string, 16 | msg: string | Node | (string | Node)[], 17 | showCancel: boolean, 18 | submitResponse: unknown | (() => unknown), 19 | cancelResponse: unknown | (() => unknown) 20 | ) { 21 | const sendResponse = (response: unknown | (() => unknown)) => this.sendResponse(uuid, (response instanceof Function) ? response() : response); 22 | 23 | const buttons: PopupButton[] = [ 24 | { 25 | text: 'OK', 26 | click: () => sendResponse(submitResponse), 27 | } 28 | ]; 29 | 30 | if (showCancel) { 31 | buttons.push({ text: 'Cancel' }); 32 | } 33 | 34 | return popup( 35 | title, 36 | msg, 37 | buttons, 38 | this.tabs.currentTab.webviewSubContainer, 39 | false, 40 | () => sendResponse(cancelResponse), 41 | () => sendResponse(submitResponse) 42 | ); 43 | } 44 | 45 | showAlert(uuid: string, message: unknown = '') { 46 | this.showDialog( 47 | uuid, 48 | 'Alert', 49 | String(message), 50 | false, 51 | undefined, 52 | undefined 53 | ); 54 | } 55 | 56 | showConfirm(uuid: string, message: unknown = '') { 57 | this.showDialog( 58 | uuid, 59 | 'Confirm', 60 | String(message), 61 | true, 62 | true, 63 | false 64 | ); 65 | } 66 | 67 | showPrompt(uuid: string, message: unknown = '', initial: unknown = '') { 68 | const input = document.createElement('input'); 69 | input.value = String(initial); 70 | input.className = 'prompt-input'; 71 | 72 | this.showDialog( 73 | uuid, 74 | 'Prompt', 75 | [String(message), input], 76 | true, 77 | () => input.value, 78 | null 79 | ); 80 | } 81 | 82 | processRequest(_: Electron.IpcRendererEvent, uuid: string, type: string, message: unknown, initial: unknown) { 83 | switch (type) { 84 | case 'alert': 85 | this.showAlert(uuid, message); 86 | break; 87 | case 'confirm': 88 | this.showConfirm(uuid, message); 89 | break; 90 | case 'prompt': 91 | this.showPrompt(uuid, message, initial); 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/window/popups/popup.ts: -------------------------------------------------------------------------------- 1 | import '../less/popup.less'; 2 | 3 | export interface PopupButton { 4 | text: string | Node; 5 | click?: () => unknown; 6 | keepOpen?: boolean; 7 | } 8 | 9 | export function popup( 10 | title: string | Node, 11 | msg: string | Node|(string | Node)[], 12 | buttons: PopupButton[] = [{text: 'OK'}], 13 | parent = document.body, 14 | small = false, 15 | onClose?: () => unknown, 16 | onSubmit?: (() => unknown) | false 17 | ) { 18 | const popupElement = document.createElement('div'), 19 | text = document.createElement('div'), 20 | buttonCnt = document.createElement('div'); 21 | 22 | let container: HTMLDivElement | undefined; 23 | 24 | popupElement.classList.add('popup'); 25 | 26 | if (small) { 27 | popupElement.classList.add('mini-popup'); 28 | 29 | parent.append(popupElement); 30 | } else { 31 | container = document.createElement('div') 32 | 33 | container.className = 'popup-container'; 34 | container.style.cssText = ''; 35 | 36 | if (parent !== document.body) { 37 | container.classList.add('partial'); 38 | } 39 | 40 | container.append(popupElement); 41 | parent.append(container); 42 | } 43 | 44 | const topElement = container ?? popupElement; 45 | 46 | if (title) { 47 | const titleElement = document.createElement('h3'); 48 | 49 | titleElement.style.cssText = 'margin: 0;'; 50 | titleElement.append(title); 51 | popupElement.append(titleElement); 52 | } 53 | 54 | if (Array.isArray(msg)) { 55 | text.append(...msg); 56 | } else { 57 | text.append(msg) 58 | } 59 | 60 | popupElement.append(text); 61 | 62 | buttonCnt.className = 'popup-buttons'; 63 | popupElement.append(buttonCnt); 64 | 65 | const closePopup = () => { 66 | topElement.remove(); 67 | onClose?.(); 68 | }; 69 | 70 | buttons.forEach(button => { 71 | const buttonElement = document.createElement('button'); 72 | 73 | buttonElement.append(button.text); 74 | buttonElement.addEventListener('click', () => { 75 | button.click?.(); 76 | if (!button.keepOpen) closePopup(); 77 | }); 78 | buttonCnt.append(buttonElement); 79 | }); 80 | 81 | topElement.addEventListener('keydown', e => { 82 | if (e.key === 'Escape') { 83 | closePopup(); 84 | return; 85 | } 86 | 87 | if (onSubmit !== false && (e.key === 'Enter' || e.key === 'NumpadEnter')) { 88 | onSubmit?.(); 89 | closePopup(); 90 | } 91 | }); 92 | 93 | topElement.tabIndex = -1; 94 | topElement.focus(); 95 | 96 | return closePopup; 97 | } -------------------------------------------------------------------------------- /src/window/popups/userSettingsData.ts: -------------------------------------------------------------------------------- 1 | import { fileTypes } from "../../utils/fileTypes"; 2 | 3 | export const userSettingsData = [ 4 | { 5 | name: 'Behaviour', 6 | settings: [ 7 | { 8 | type: 'checkbox', 9 | name: 'autoRun', 10 | label: 'Automatic preview' 11 | }, 12 | { 13 | type: 'checkbox', 14 | name: 'autoSave', 15 | label: 'Autosave' 16 | }, 17 | { 18 | type: 'checkbox', 19 | name: 'autoEdit', 20 | label: 'Show editor on launch with file' 21 | }, 22 | { 23 | type: 'checkbox', 24 | name: 'autoDevtools', 25 | label: 'Show devtools on launch' 26 | }, 27 | { 28 | type: 'checkbox', 29 | name: 'autoSwitcher', 30 | label: 'Show file switcher by default' 31 | }, 32 | { 33 | type: 'select', 34 | name: 'defaultType', 35 | label: 'Default file type', 36 | values: fileTypes.map(typeInfo => ({ 37 | value: typeInfo.type, 38 | label: typeInfo.name 39 | })) 40 | } 41 | ] 42 | }, 43 | { 44 | name: 'Appearance', 45 | settings: [ 46 | { 47 | type: 'select', 48 | name: 'theme', 49 | label: 'Theme', 50 | values: [ 51 | { 52 | value: 'dark', 53 | label: 'Dark' 54 | }, 55 | { 56 | value: 'light', 57 | label: 'Light' 58 | }, 59 | { 60 | value: 'system', 61 | label: 'System' 62 | } 63 | ] 64 | }, 65 | { 66 | type: 'checkbox', 67 | name: 'viewerUseTheme', 68 | label: 'Apply theme to viewer' 69 | } 70 | ] 71 | }, 72 | { 73 | name: 'Editor', 74 | settings: [ 75 | { 76 | type: 'checkbox', 77 | name: 'softTabs', 78 | label: 'Use spaces as tabs' 79 | }, 80 | { 81 | type: 'number', 82 | name: 'tabSize', 83 | label: 'Tab length' 84 | }, 85 | { 86 | type: 'checkbox', 87 | name: 'showInvisible', 88 | label: 'Show invisible characters' 89 | }, 90 | { 91 | type: 'checkbox', 92 | name: 'gutter', 93 | label: 'Show line numbers' 94 | }, 95 | { 96 | type: 'checkbox', 97 | name: 'wordWrap', 98 | label: 'Wrap long lines' 99 | }, 100 | { 101 | type: 'checkbox', 102 | name: 'autocomplete', 103 | label: 'Autocompletion' 104 | }, 105 | { 106 | type: 'checkbox', 107 | name: 'enableSnippets', 108 | label: 'Enable code snippets' 109 | } 110 | ] 111 | } 112 | ]; -------------------------------------------------------------------------------- /src/window/userOptions.ts: -------------------------------------------------------------------------------- 1 | import { Ace } from 'ace-builds'; 2 | import SettingStore from '../utils/SettingStore'; 3 | import ThemeMode from './ThemeMode'; 4 | import Tabs from './Tabs'; 5 | 6 | type SessionlessSettings = Exclude; 7 | 8 | const editorSettings: Partial> = { 9 | enableBasicAutocompletion: 'autocomplete', 10 | enableLiveAutocompletion: 'autocomplete', 11 | showGutter: 'gutter', 12 | enableSnippets: 'enableSnippets', 13 | showInvisibles: 'showInvisible' 14 | }; 15 | 16 | const sessionSettings: Partial> = { 17 | useSoftTabs: 'softTabs', 18 | wrap: 'wordWrap', 19 | tabSize: 'tabSize', 20 | }; 21 | 22 | export function initializeSettings( 23 | settings: SettingStore, 24 | themeMode: ThemeMode, 25 | editor: Ace.Editor, 26 | tabs: Tabs 27 | ) { 28 | for (const [aceSetting, storeSetting] of Object.entries(editorSettings)) { 29 | settings.listen( 30 | storeSetting, 31 | value => editor.setOption(aceSetting as keyof Ace.EditorOptions, value) 32 | ); 33 | } 34 | 35 | for (const [aceSetting, storeSetting] of Object.entries(sessionSettings)) { 36 | settings.listen( 37 | storeSetting, 38 | value => tabs.tabs.forEach(tab => { 39 | tab.editorSession.setOption(aceSetting as keyof Ace.EditSessionOptions, value); 40 | }) 41 | ); 42 | } 43 | 44 | const setAceTheme = () => editor.setTheme('ace/theme/' + (themeMode.darkMode ? 'clouds_midnight' : 'clouds')); 45 | themeMode.addListener('change', () => setAceTheme()); 46 | settings.listen('theme', () => setAceTheme()); 47 | 48 | settings.callAndListen('viewerUseTheme', useTheme => { 49 | document.querySelector('#webview-container')?.classList.toggle('use-theme', useTheme); 50 | }); 51 | } 52 | 53 | export function getEditorOptions(settings: SettingStore, themeMode: ThemeMode): Partial> { 54 | return { 55 | showPrintMargin: false, 56 | theme: 'ace/theme/' + (themeMode.darkMode ? 'clouds_midnight' : 'clouds'), 57 | ...Object.fromEntries(Object.entries(editorSettings).map(([aceSetting, storeSetting]) => { 58 | return [aceSetting, settings.get(storeSetting)]; 59 | })) 60 | }; 61 | } 62 | 63 | export function getSessionOptions(settings: SettingStore): Partial { 64 | return { 65 | useWorker: false, 66 | ...Object.fromEntries(Object.entries(sessionSettings).map(([aceSetting, storeSetting]) => { 67 | return [aceSetting, settings.get(storeSetting)]; 68 | })) 69 | }; 70 | } -------------------------------------------------------------------------------- /src/window/popups/UserSettingsPopup.ts: -------------------------------------------------------------------------------- 1 | import SettingStore from '../../utils/SettingStore'; 2 | import { popup } from './popup'; 3 | import '../less/userSettings.less'; 4 | import { userSettingsData } from './userSettingsData'; 5 | 6 | type Section = typeof userSettingsData[0]; 7 | type Setting = typeof userSettingsData[0]['settings'][0]; 8 | 9 | export default class UserSettingsPopup { 10 | settings: SettingStore; 11 | 12 | constructor(settings: SettingStore) { 13 | this.settings = settings; 14 | } 15 | 16 | show() { 17 | popup('Settings', userSettingsData.flatMap(section => this.createSection(section))); 18 | } 19 | 20 | createSection(section: Section) { 21 | const heading = document.createElement('h4'); 22 | heading.className = 'row setting-section-heading'; 23 | heading.textContent = section.name; 24 | 25 | return [heading, ...section.settings.map(setting => this.createSetting(setting))] 26 | } 27 | 28 | createSetting(setting: Setting) { 29 | const el = document.createElement('label'); 30 | el.className = 'row setting-row'; 31 | 32 | switch (setting.type) { 33 | case 'checkbox': 34 | this.createCheckbox(el, setting); 35 | break; 36 | case 'select': 37 | this.createSelect(el, setting); 38 | break; 39 | case 'text': 40 | case 'number': 41 | this.createInput(el, setting); 42 | } 43 | 44 | return el; 45 | } 46 | 47 | createCheckbox(parent: HTMLElement, setting: Setting) { 48 | const checkbox = document.createElement('input'); 49 | 50 | checkbox.type = 'checkbox'; 51 | checkbox.checked = this.settings.get(setting.name); 52 | checkbox.addEventListener('change', () => this.settings.set(setting.name, checkbox.checked)); 53 | parent.append(setting.label, checkbox); 54 | } 55 | 56 | createSelect(parent: HTMLElement, setting: Setting) { 57 | const select = document.createElement('select'); 58 | 59 | select.addEventListener('change', () => this.settings.set(setting.name, select.value)); 60 | setting.values?.forEach(({label, value}) => { 61 | const option = document.createElement('option'); 62 | 63 | option.append(label); 64 | option.selected = value === this.settings.get(setting.name); 65 | option.value = value; 66 | select.append(option); 67 | }); 68 | 69 | parent.append(setting.label, select); 70 | } 71 | 72 | createInput(parent: HTMLElement, setting: Setting) { 73 | const input = document.createElement('input'); 74 | 75 | input.type = setting.type; 76 | input.value = this.settings.get(setting.name); 77 | input.addEventListener('change', () => this.settings.set(setting.name, input.value)); 78 | parent.append(setting.label, input); 79 | } 80 | } -------------------------------------------------------------------------------- /src/window/MenuActionProcessor.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import showAbout from './popups/showAbout'; 3 | import openFile from './openFile'; 4 | import UserSettingsPopup from './popups/UserSettingsPopup'; 5 | import SettingStore from '../utils/SettingStore'; 6 | import SplitElement from './SplitElement'; 7 | import Tabs from './Tabs'; 8 | 9 | export default class MenuActionProcessor { 10 | constructor( 11 | private tabs: Tabs, 12 | private mainSplit: SplitElement, 13 | private viewerSplit: SplitElement, 14 | private settings: SettingStore 15 | ) { } 16 | 17 | processRequest(_: Electron.IpcRendererEvent, action: string, mode?: string) { 18 | switch (action) { 19 | case 'print': 20 | this.tabs.currentTab.webview.print({ silent: false }); 21 | break; 22 | case 'find': 23 | this.tabs.currentTab.miniPopupFactory.showFindPopup(); 24 | break; 25 | case 'zoom': 26 | this.tabs.currentTab.miniPopupFactory.showZoomPopup(); 27 | break; 28 | case 'terminate': 29 | ipcRenderer.send('crash-renderer', this.tabs.currentTab.webview.getWebContentsId()); 30 | break; 31 | case 'format': 32 | window.formatEditor(); 33 | break; 34 | case 'rotate-editor': 35 | this.settings.set('editorDirection', this.mainSplit.toggleDirection()); 36 | break; 37 | case 'rotate-devtools': 38 | this.settings.set('devtoolsDirection', this.viewerSplit.toggleDirection()); 39 | break; 40 | case 'save': 41 | this.tabs.currentTab.save(); 42 | break; 43 | case 'save-as': 44 | this.tabs.currentTab.save(SaveType.SetName); 45 | break; 46 | case 'settings': 47 | new UserSettingsPopup(this.settings).show(); 48 | break; 49 | case 'about': 50 | showAbout(); 51 | break; 52 | case 'run': 53 | this.tabs.currentTab.preview(); 54 | break; 55 | case 'devtools': 56 | this.viewerSplit.toggleVisible(true); 57 | break; 58 | case 'open': 59 | openFile(this.tabs); 60 | break; 61 | case 'toggle-devtools': 62 | this.viewerSplit.toggleVisible(); 63 | break; 64 | case 'toggle-editor': 65 | this.mainSplit.toggleVisible(); 66 | break; 67 | case 'close': 68 | this.tabs.currentTab.close(); 69 | break; 70 | case 'new': 71 | this.tabs.createTab({ mode: mode ?? this.tabs.currentTab.mode }); 72 | break; 73 | case 'prev-tab': 74 | this.tabs.selectPrev(); 75 | break; 76 | case 'next-tab': 77 | this.tabs.selectNext(); 78 | break; 79 | case 'back': 80 | this.tabs.currentTab.webview.goBack(); 81 | break; 82 | case 'forward': 83 | this.tabs.currentTab.webview.goForward(); 84 | break; 85 | case 'close-mini': 86 | this.tabs.currentTab.miniPopupFactory?.currentPopup?.dispose(); 87 | break; 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/main/SessionManager.ts: -------------------------------------------------------------------------------- 1 | import { session, Session } from 'electron'; 2 | import { fileURLToPath } from 'url'; 3 | import fs from 'fs/promises'; 4 | import convertText from './convertText'; 5 | import { getFileType } from '../utils/fileTypes'; 6 | 7 | export default class SessionManager { 8 | sessions: Record = {}; 9 | 10 | private textResponse(mode: string, text: string) { 11 | return new Response(convertText(mode, text), { 12 | headers: { 'content-type': 'text/html' } 13 | }); 14 | } 15 | 16 | private defaultResponse(req: Request, ses: Session) { 17 | return ses.fetch(req, { bypassCustomProtocolHandlers: true }); 18 | } 19 | 20 | private configureIntercept(partition: string, ses: Session) { 21 | ses.protocol.handle('file', async (req) => { 22 | const { file, mode, text } = this.sessions[partition]; 23 | 24 | if (new URL(req.url).hostname === partition) 25 | return this.textResponse(mode, text); 26 | 27 | let requestedFile: string; 28 | 29 | try { 30 | requestedFile = fileURLToPath(req.url); 31 | } catch (_) { 32 | return this.defaultResponse(req, ses); 33 | } 34 | 35 | if (requestedFile === file) 36 | return this.textResponse(mode, text); 37 | 38 | const requestedFileMode = getFileType(requestedFile); 39 | 40 | if (!requestedFileMode) 41 | return this.defaultResponse(req, ses); 42 | 43 | return this.textResponse( 44 | requestedFileMode, 45 | await fs.readFile(requestedFile, 'utf8') 46 | ); 47 | }); 48 | } 49 | 50 | private start(partition: string, file: string, mode: string, text: string) { 51 | const ses = session.fromPartition(partition); 52 | 53 | this.sessions[partition] = { file, mode, text, ses }; 54 | this.configureIntercept(partition, ses); 55 | } 56 | 57 | private update(partition: string, file: string, mode: string, text: string) { 58 | this.sessions[partition].file = file; 59 | this.sessions[partition].mode = mode; 60 | this.sessions[partition].text = text; 61 | } 62 | 63 | set(partition: string, file: string, mode: string, text: string) { 64 | if (!(partition in this.sessions)) { 65 | this.start(partition, file, mode, text); 66 | return; 67 | } 68 | 69 | this.update(partition, file, mode, text); 70 | } 71 | 72 | delete(partition: string) { 73 | this.sessions[partition]?.ses.clearStorageData(); 74 | 75 | delete this.sessions[partition]; 76 | } 77 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "burrow", 3 | "description": "HTML, markdown, and SVG viewer and code editor with development tools.", 4 | "version": "1.9.0", 5 | "homepage": "https://www.brandonfowler.me/burrow/", 6 | "author": { 7 | "name": "Brandon Fowler", 8 | "email": "burrow@brandonfowler.me", 9 | "url": "https://www.brandonfowler.me/" 10 | }, 11 | "license": "LGPL-3.0-or-later", 12 | "main": "./build/main.js", 13 | "scripts": { 14 | "start": "webpack --mode=development && electron .", 15 | "build": "webpack --mode=production && electron-builder --publish never", 16 | "release": "webpack --mode=production && electron-builder", 17 | "lint": "eslint --ext .ts ." 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/BrandonXLF/burrow.git" 22 | }, 23 | "devDependencies": { 24 | "@types/lodash.throttle": "^4.1.9", 25 | "@types/showdown": "^2.0.6", 26 | "@typescript-eslint/eslint-plugin": "^8.24.1", 27 | "@typescript-eslint/parser": "^8.24.1", 28 | "css-loader": "^7.1.2", 29 | "electron": "^36", 30 | "electron-builder": "^26.0.7", 31 | "eslint": "^8.57.0", 32 | "eslint-plugin-deprecation": "^3.0.0", 33 | "file-loader": "^6.2.0", 34 | "html-loader": "^5.0.0", 35 | "html-webpack-plugin": "^5.6.0", 36 | "less": "^4.2.0", 37 | "less-loader": "^12.2.0", 38 | "mini-css-extract-plugin": "^2.9.0", 39 | "node-loader": "^2.0.0", 40 | "style-loader": "^4.0.0", 41 | "ts-loader": "^9.5.1", 42 | "ts-node": "^10.9.2", 43 | "typescript": "^5.5.3", 44 | "webpack-cli": "^6.0.1" 45 | }, 46 | "dependencies": { 47 | "@prettier/plugin-xml": "^3.4.1", 48 | "ace-builds": "^1.35.2", 49 | "electron-store": "^10.0.0", 50 | "electron-updater": "^6.2.1", 51 | "lodash.throttle": "^4.1.1", 52 | "prettier": "^3.5.1", 53 | "showdown": "^2.1.0" 54 | }, 55 | "build": { 56 | "appId": "me.brandonfowler.burrow", 57 | "productName": "Burrow HTML and Markdown", 58 | "executableName": "burrow", 59 | "files": [ 60 | "build/*", 61 | "package.json" 62 | ], 63 | "extraFiles": [ 64 | "LICENSE.md" 65 | ], 66 | "fileAssociations": [ 67 | { 68 | "ext": "html", 69 | "name": "HTML File" 70 | }, 71 | { 72 | "ext": "htm", 73 | "name": "HTML File" 74 | }, 75 | { 76 | "ext": "md", 77 | "name": "Markdown File" 78 | }, 79 | { 80 | "ext": "markdown", 81 | "name": "Markdown File" 82 | }, 83 | { 84 | "ext": "svg", 85 | "name": "SVG File" 86 | } 87 | ], 88 | "publish": [ 89 | "github" 90 | ], 91 | "win": { 92 | "target": [ 93 | "nsis", 94 | "portable" 95 | ] 96 | }, 97 | "mac": { 98 | "target": "default" 99 | }, 100 | "linux": { 101 | "target": [ 102 | "deb", 103 | "rpm", 104 | "AppImage" 105 | ], 106 | "category": "Development" 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/window/less/top.less: -------------------------------------------------------------------------------- 1 | header { 2 | -webkit-app-region: drag; 3 | display: flex; 4 | user-select: none; 5 | background: var(--top-color); 6 | white-space: nowrap; 7 | 8 | button { 9 | -webkit-app-region: no-drag; 10 | border: none; 11 | background: none; 12 | display: inline-flex; 13 | align-items: center; 14 | justify-content: center; 15 | height: 30px; 16 | padding: 5px; 17 | transition: color 0.25s, background 0.25s; 18 | cursor: pointer; 19 | } 20 | } 21 | 22 | .tab { 23 | @inner-tab-spacing: 4px; 24 | 25 | padding: 0 @inner-tab-spacing; 26 | display: flex; 27 | align-items: center; 28 | overflow: hidden; 29 | 30 | .tab-icon-mixin() { 31 | width: 14px; 32 | height: 14px; 33 | padding: 2px; 34 | } 35 | 36 | .tab-unsaved { 37 | display: none; 38 | } 39 | 40 | &.unsaved { 41 | .tab-unsaved { 42 | display: inline-flex; 43 | align-items: center; 44 | justify-content: center; 45 | .tab-icon-mixin(); 46 | margin-right: @inner-tab-spacing; 47 | } 48 | } 49 | 50 | .tab-favicon[src] { 51 | max-height: 16px; 52 | margin-right: @inner-tab-spacing; 53 | } 54 | 55 | .tab-title { 56 | overflow: hidden; 57 | max-width: 10em; 58 | } 59 | 60 | .tab-close { 61 | .tab-icon-mixin(); 62 | margin-left: @inner-tab-spacing; 63 | } 64 | 65 | &:hover { 66 | background: var(--button-color); 67 | } 68 | 69 | &.current { 70 | background: var(--bg-color); 71 | } 72 | } 73 | 74 | #chrome { 75 | display: flex; 76 | flex: 1; 77 | overflow: hidden; 78 | gap: 3px; 79 | } 80 | 81 | #top-icon { 82 | height: 30px; 83 | aspect-ratio: 1 / 1; 84 | padding: 5px 3px 5px 6px; 85 | } 86 | 87 | .top-button { 88 | &:not([disabled]):hover { 89 | background: var(--button-color); 90 | } 91 | 92 | &[disabled] { 93 | opacity: 0.5; 94 | } 95 | 96 | > svg { 97 | width: 20px; 98 | height: 20px; 99 | } 100 | } 101 | 102 | #tabs { 103 | -webkit-app-region: no-drag; 104 | display: flex; 105 | overflow: hidden; 106 | } 107 | 108 | #os-drag { 109 | width: 1em; 110 | height: 100%; 111 | } 112 | 113 | .window-button { 114 | height: 30px; 115 | padding: 0 10px; 116 | user-select: none; 117 | -webkit-app-region: no-drag; 118 | transition: color 0.25s, background 0.25s; 119 | 120 | &:not(#exit) { 121 | &:hover { 122 | background-color: var(--button-color); 123 | } 124 | } 125 | 126 | &#close { 127 | &:hover { 128 | background-color: var(--close-color); 129 | color: #fff; 130 | } 131 | } 132 | 133 | &#update { 134 | &[data-update-state="downloaded"] { 135 | background-color: var(--update-color); 136 | } 137 | } 138 | } 139 | 140 | body.maximized { 141 | #maximize { 142 | display: none; 143 | } 144 | } 145 | 146 | body:not(.maximized) { 147 | #unmaximize { 148 | display: none; 149 | } 150 | } 151 | 152 | [data-editor] #edit, 153 | [data-devtools] #inspect, 154 | [data-switcher]:not([data-no-path]) #switch { 155 | background: var(--bg-color); 156 | } -------------------------------------------------------------------------------- /src/window/SplitElement.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import './less/split.less'; 3 | 4 | export default class SplitElement extends EventEmitter { 5 | identifier: string; 6 | element: HTMLElement; 7 | resizeElement: HTMLElement; 8 | firstChild: HTMLElement; 9 | mouseMoveBound: (e: MouseEvent) => void; 10 | 11 | constructor( 12 | identifier: string, 13 | element: HTMLElement, 14 | direction: 'horizontal' | 'vertical', 15 | visible: boolean, 16 | width: string, 17 | height: string, 18 | private readonly usePixels = false 19 | ) { 20 | super(); 21 | 22 | this.identifier = identifier; 23 | this.element = element; 24 | this.resizeElement = element.querySelector(':scope > .resize')!; 25 | this.firstChild = element.firstElementChild as HTMLElement; 26 | this.mouseMoveBound = this.mouseMove.bind(this); 27 | 28 | this.firstChild.style.width = width; 29 | this.firstChild.style.height = height; 30 | 31 | this.toggleDirection(direction); 32 | this.toggleVisible(visible); 33 | 34 | this.addMouseEvents(); 35 | this.positionResize(); 36 | } 37 | 38 | addMouseEvents(): void { 39 | this.resizeElement.addEventListener('mousedown', () => { 40 | this.resizeElement.classList.add('active'); 41 | this.resizeElement.addEventListener('mousemove', this.mouseMoveBound); 42 | }); 43 | 44 | this.resizeElement.addEventListener('mouseup', () => { 45 | this.resizeElement.removeEventListener('mousemove', this.mouseMoveBound); 46 | this.resizeElement.classList.remove('active'); 47 | 48 | if (this.element.classList.contains('horizontal')) { 49 | this.emit('width', this.firstChild.style.width); 50 | } else { 51 | this.emit('height', this.firstChild.style.height); 52 | } 53 | }); 54 | } 55 | 56 | getLength(offset: number, total: number): string { 57 | if (this.usePixels) { 58 | return offset + 'px'; 59 | } 60 | 61 | return offset / total * 100 + '%'; 62 | } 63 | 64 | positionResize(): void { 65 | if (this.element.classList.contains('horizontal')) { 66 | this.resizeElement.style.left = this.getLength(this.firstChild.offsetWidth, this.element.offsetWidth); 67 | } else { 68 | this.resizeElement.style.top = this.getLength(this.firstChild.offsetHeight, this.element.offsetHeight); 69 | } 70 | } 71 | 72 | mouseMove(event: MouseEvent): void { 73 | if (this.element.classList.contains('horizontal')) { 74 | this.firstChild.style.width = this.getLength(event.offsetX, this.element.offsetWidth); 75 | } else { 76 | this.firstChild.style.height = this.getLength(event.offsetY, this.element.offsetHeight); 77 | } 78 | 79 | this.positionResize(); 80 | } 81 | 82 | toggleVisible(force?: boolean): boolean { 83 | const visible = !this.element.classList.toggle('hidden', force !== undefined ? !force : undefined); 84 | 85 | if (visible) { 86 | document.body.setAttribute(`data-${this.identifier}`, ''); 87 | } else { 88 | document.body.removeAttribute(`data-${this.identifier}`); 89 | } 90 | 91 | this.positionResize(); 92 | this.emit('visible', visible); 93 | 94 | return visible; 95 | } 96 | 97 | toggleDirection(force?: 'horizontal' | 'vertical'): 'horizontal' | 'vertical' { 98 | const becomeVertical = force !== undefined ? force === 'vertical' : !this.element.classList.contains('vertical'); 99 | 100 | this.element.classList.toggle('vertical', becomeVertical); 101 | this.element.classList.toggle('horizontal', !becomeVertical); 102 | 103 | this.positionResize(); 104 | 105 | return becomeVertical ? 'vertical' : 'horizontal'; 106 | } 107 | } -------------------------------------------------------------------------------- /src/window/FileSwitcher.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import { join, dirname } from 'path'; 3 | import { getFileType } from '../utils/fileTypes'; 4 | import Tab from './Tab'; 5 | import './less/switcher.less'; 6 | 7 | export default class FileSwitcher { 8 | el = document.createElement('div'); 9 | controller?: AbortController; 10 | folder: string; 11 | observer = new IntersectionObserver(entries => { 12 | const intersecting = entries.some(entry => entry.isIntersecting); 13 | 14 | if (!intersecting && this.controller) { 15 | this.controller?.abort(); 16 | this.controller = undefined; 17 | } else if (intersecting && !this.controller) { 18 | this.watchFolder(); 19 | } 20 | }); 21 | 22 | constructor(public readonly tab: Tab) { 23 | this.el.classList.add('show-when-current'); 24 | this.el.role = 'tree'; 25 | 26 | this.observer.observe(this.el); 27 | } 28 | 29 | async watchFolder(): Promise { 30 | if (!this.folder) return; 31 | 32 | this.controller?.abort(); 33 | this.controller = new AbortController(); 34 | 35 | const watcher = fs.watch(this.folder, { 36 | signal: this.controller.signal, 37 | recursive: true 38 | }); 39 | 40 | this.populateSwitcher(); 41 | 42 | try { 43 | for await (const _ of watcher) { 44 | this.populateSwitcher(); 45 | } 46 | } catch (err) { 47 | if (err.name !== 'AbortError') throw err; 48 | } 49 | } 50 | 51 | private async createSwitcherTree(dirPath: string, expandChildren: boolean): Promise { 52 | const items = await fs.readdir(dirPath, { withFileTypes: true }); 53 | const group = document.createElement('div'); 54 | group.role = 'group'; 55 | group.className = 'switcher-group'; 56 | 57 | if (expandChildren) { 58 | const el = document.createElement('div'); 59 | el.role = 'treeitem'; 60 | el.className = 'switcher-item switcher-name'; 61 | el.innerText = '..'; 62 | el.ariaSelected = 'false'; 63 | 64 | el.addEventListener('click', () => { 65 | this.folder = dirname(dirPath); 66 | this.populateSwitcher(); 67 | }); 68 | 69 | group.append(el); 70 | } 71 | 72 | for (const item of items) { 73 | const path = join(item.parentPath, item.name); 74 | 75 | if (item.isFile()) { 76 | const fileType = getFileType(join(item.parentPath, item.name)); 77 | if (!fileType) continue; 78 | 79 | const el = document.createElement('div'); 80 | el.role = 'treeitem'; 81 | el.className = 'switcher-item switcher-name'; 82 | el.innerText = item.name; 83 | el.ariaSelected = (this.tab.path === path).toString(); 84 | 85 | el.addEventListener('click', () => { 86 | this.tab.setPath(path, true, true); 87 | this.populateSwitcher(); 88 | }); 89 | 90 | group.append(el); 91 | continue; 92 | } 93 | 94 | if (!expandChildren) continue; 95 | 96 | if (item.isDirectory()) { 97 | const cnt = document.createElement('div'); 98 | cnt.role = 'treeitem'; 99 | cnt.className = 'switcher-item'; 100 | cnt.ariaExpanded = expandChildren ? 'true' : 'false'; 101 | 102 | const el = document.createElement('div'); 103 | el.className = 'switcher-name'; 104 | el.innerText = item.name; 105 | 106 | el.addEventListener('click', () => { 107 | this.folder = path; 108 | this.populateSwitcher(); 109 | }); 110 | 111 | cnt.append(el, await this.createSwitcherTree(path, false)); 112 | group.append(cnt); 113 | } 114 | } 115 | 116 | return group; 117 | } 118 | 119 | async populateSwitcher() { 120 | if (!this.folder) return; 121 | 122 | this.el.replaceChildren( 123 | await this.createSwitcherTree(this.folder, true) 124 | ); 125 | } 126 | 127 | dispose(): void { 128 | this.controller?.abort(); 129 | this.observer.disconnect(); 130 | this.el.remove(); 131 | } 132 | } -------------------------------------------------------------------------------- /src/window/popups/MiniPopupFactory.ts: -------------------------------------------------------------------------------- 1 | import Tab from '../Tab'; 2 | import { useSVG } from '../useSVG'; 3 | import { popup } from './popup'; 4 | 5 | export default class MiniPopupFactory { 6 | static ZOOM_LEVELS = [0.25, 1/3, 0.5, 2/3, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; 7 | 8 | currentPopup: { 9 | type: string; 10 | dispose: () => unknown; 11 | } | undefined; 12 | 13 | constructor(private tab: Tab) { } 14 | 15 | showZoomPopup() { 16 | if (this.currentPopup) { 17 | if (this.currentPopup.type === 'zoom') return; 18 | this.currentPopup.dispose(); 19 | } 20 | 21 | const zoomText = new Text('100%'), 22 | showZoom = (zoom: number) => zoomText.data = Math.round(zoom * 100) + '%'; 23 | 24 | const initialZoom = this.tab.webview.getZoomFactor(); 25 | 26 | let zoomIndex = 0; 27 | 28 | while (MiniPopupFactory.ZOOM_LEVELS[zoomIndex] < initialZoom) zoomIndex++; 29 | 30 | showZoom(initialZoom); 31 | 32 | const closePopup = popup( 33 | '', 34 | zoomText, 35 | [ 36 | { 37 | text: useSVG('minus'), 38 | click: () => { 39 | zoomIndex = Math.max(zoomIndex - 1, 0); 40 | this.tab.webview.setZoomFactor(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 41 | showZoom(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 42 | }, 43 | keepOpen: true 44 | }, 45 | { 46 | text: useSVG('plus'), click: () => { 47 | zoomIndex = Math.min(zoomIndex + 1, MiniPopupFactory.ZOOM_LEVELS.length - 1); 48 | this.tab.webview.setZoomFactor(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 49 | showZoom(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 50 | }, 51 | keepOpen: true 52 | }, 53 | { 54 | text: 'Reset', click: () => { 55 | zoomIndex = MiniPopupFactory.ZOOM_LEVELS.indexOf(1); 56 | this.tab.webview.setZoomFactor(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 57 | showZoom(MiniPopupFactory.ZOOM_LEVELS[zoomIndex]); 58 | }, 59 | keepOpen: true 60 | }, 61 | { 62 | text: useSVG('x') 63 | }, 64 | ], 65 | this.tab.webviewSubContainer, 66 | true, 67 | () => { 68 | this.tab.webview.focus(); 69 | this.currentPopup = undefined; 70 | }, 71 | false 72 | ); 73 | 74 | this.currentPopup = { 75 | type: 'zoom', 76 | dispose: closePopup 77 | }; 78 | 79 | return closePopup; 80 | } 81 | 82 | showFindPopup() { 83 | if (this.currentPopup) { 84 | if (this.currentPopup.type === 'find') return; 85 | this.currentPopup.dispose(); 86 | } 87 | 88 | const input = document.createElement('input'), 89 | current = new Text('0'), 90 | sep = new Text('/'), 91 | total = new Text('0'), 92 | onFoundInPage = (e: Electron.FoundInPageEvent) => { 93 | current.data = e.result.activeMatchOrdinal.toString(); 94 | total.data = e.result.matches.toString(); 95 | }; 96 | 97 | this.tab.webview.addEventListener('found-in-page', onFoundInPage); 98 | 99 | input.style.marginRight = '4px'; 100 | 101 | input.addEventListener('input', () => { 102 | this.tab.webview.stopFindInPage('clearSelection'); 103 | 104 | this.tab.webview.findInPage(input.value, { 105 | findNext: true 106 | }); 107 | }); 108 | 109 | input.addEventListener('keydown', e => { 110 | if (e.key === 'Enter' || e.key === 'NumpadEnter') { 111 | this.tab.webview.findInPage(input.value, { 112 | forward: !e.shiftKey, 113 | findNext: false 114 | }); 115 | } 116 | }); 117 | 118 | const closePopup = popup( 119 | '', 120 | [input, current, sep, total], 121 | [ 122 | { 123 | text: useSVG('prev'), 124 | click: () => { 125 | this.tab.webview.findInPage(input.value, { 126 | forward: false, 127 | findNext: false 128 | }); 129 | }, 130 | keepOpen: true 131 | }, 132 | { 133 | text: useSVG('next'), 134 | click: () => { 135 | this.tab.webview.findInPage(input.value, { 136 | forward: true, 137 | findNext: false 138 | }) 139 | }, 140 | keepOpen: true 141 | }, 142 | { 143 | text: useSVG('x') 144 | }, 145 | ], 146 | this.tab.webviewSubContainer, 147 | true, 148 | () => { 149 | this.tab.webview.stopFindInPage('clearSelection'); 150 | this.tab.webview.removeEventListener('found-in-page', onFoundInPage); 151 | this.tab.webview.focus(); 152 | this.currentPopup = undefined; 153 | }, 154 | false 155 | ); 156 | 157 | input.focus(); 158 | 159 | this.currentPopup = { 160 | type: 'find', 161 | dispose: closePopup 162 | }; 163 | 164 | return closePopup; 165 | } 166 | } -------------------------------------------------------------------------------- /src/window/Tabs.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import Tab from './Tab'; 3 | import SettingStore from '../utils/SettingStore'; 4 | import { Ace } from 'ace-builds'; 5 | import { getFileType } from '../utils/fileTypes'; 6 | import { popup } from './popups/popup'; 7 | import { extname } from 'path'; 8 | import ThemeMode from './ThemeMode'; 9 | 10 | export default class Tabs { 11 | tabs: Tab[] = []; 12 | currentTab: Tab; 13 | baseRowX: number; 14 | 15 | private tabIdCounter = 0; 16 | private tabMap: Record> = {}; 17 | 18 | constructor( 19 | public tabRow: HTMLElement, 20 | public webviewContainer: HTMLElement, 21 | public devtoolContainer: HTMLElement, 22 | public switcherContainer: HTMLElement, 23 | public editor: Ace.Editor, 24 | public webContentsIdPromise: Promise, 25 | public settings: SettingStore, 26 | public themeMode: ThemeMode 27 | ) { 28 | this.baseRowX = tabRow.getBoundingClientRect().x; 29 | } 30 | 31 | addTab(tab: Tab, index?: number): void { 32 | if (index === undefined) index = this.tabs.length; 33 | 34 | this.tabs.splice(index, 0, tab); 35 | 36 | const nextElement = this.tabRow.children[index]; 37 | 38 | if (nextElement) { 39 | nextElement.before(tab.tabElement); 40 | } else { 41 | this.tabRow.append(tab.tabElement); 42 | } 43 | 44 | this.tabMap[tab.tabId] = new WeakRef(tab); 45 | 46 | this.selectTab(tab); 47 | 48 | if (this.tabs[index - 1] && !this.tabs[index - 1].path && !this.tabs[index - 1].editorSession.getValue()) { 49 | this.removeTab(this.tabs[index - 1]); 50 | } 51 | } 52 | 53 | removeTab(tab: Tab): void { 54 | const index = this.getTabIndex(tab); 55 | 56 | this.tabs.splice(index, 1); 57 | tab.dispose(); 58 | 59 | const newTab = this.tabs[index] ?? this.tabs[index - 1] ?? this.tabs[0]; 60 | 61 | if (!newTab) { 62 | ipcRenderer.send('perform-window-action', 'close'); 63 | return; 64 | } 65 | 66 | this.selectTab(newTab); 67 | } 68 | 69 | moveTab(tab: Tab, newIndex?: number): void { 70 | if (newIndex === undefined) newIndex = this.tabs.length; 71 | 72 | const index = this.getTabIndex(tab); 73 | 74 | this.tabs.splice(index, 1); 75 | this.tabs.splice(newIndex, 0, tab); 76 | 77 | const nextElement = this.tabRow.children[newIndex]; 78 | 79 | if (nextElement) { 80 | nextElement.before(tab.tabElement); 81 | } else { 82 | this.tabRow.append(tab.tabElement); 83 | } 84 | } 85 | 86 | async createTab(data?: TabData, index?: number): Promise { 87 | this.addTab(new Tab(this, await this.getNewTabId(), data), index); 88 | } 89 | 90 | createFromFile(path: string, index?: number) { 91 | if (!getFileType(path)) { 92 | popup('Failed to open file', `Unsupported file type ${extname(path)}`); 93 | return; 94 | } 95 | 96 | this.createTab({ path }, index); 97 | } 98 | 99 | selectTab(tab: Tab): void { 100 | this.editor.setSession(tab.editorSession); 101 | 102 | document.querySelectorAll('.current').forEach(x => x.classList.remove('current')); 103 | 104 | this.currentTab = tab; 105 | this.updateNoPathAttribute(); 106 | 107 | tab.tabElement.classList.add('current'); 108 | tab.webviewSubContainer.classList.add('current'); 109 | tab.devtools.classList.add('current'); 110 | tab.switcher.el.classList.add('current'); 111 | } 112 | 113 | updateNoPathAttribute() { 114 | if (this.currentTab.path) { 115 | document.body.removeAttribute(`data-no-path`); 116 | (document.getElementById('switch') as HTMLButtonElement).disabled = false; 117 | } else { 118 | document.body.setAttribute(`data-no-path`, ''); 119 | (document.getElementById('switch') as HTMLButtonElement).disabled = true; 120 | } 121 | } 122 | 123 | selectPrev(): void { 124 | const index = this.getTabIndex(this.currentTab); 125 | 126 | this.selectTab(this.tabs[index - 1] ?? this.tabs[this.tabs.length - 1]); 127 | } 128 | 129 | selectNext(): void { 130 | const index = this.getTabIndex(this.currentTab); 131 | 132 | this.selectTab(this.tabs[index + 1] ?? this.tabs[0]); 133 | } 134 | 135 | addToMainArea(...elements: HTMLElement[]): void { 136 | this.webviewContainer.append(...elements); 137 | } 138 | 139 | addToDevtoolsArea(...elements: HTMLElement[]): void { 140 | this.devtoolContainer.append(...elements); 141 | } 142 | 143 | addToSwitcherArea(...elements: HTMLElement[]): void { 144 | this.switcherContainer.append(...elements); 145 | } 146 | 147 | async getNewTabId(): Promise { 148 | return `${await this.webContentsIdPromise}-${this.tabIdCounter++}`; 149 | } 150 | 151 | getTabById(id: string): Tab|undefined { 152 | return this.tabMap[id]?.deref(); 153 | } 154 | 155 | getTabIndex(tab: Tab): number { 156 | return this.tabs.indexOf(tab); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, webContents, dialog, nativeTheme, Menu } from 'electron'; 2 | import { getOpenFilters, getSaveFilters } from '../utils/fileTypes'; 3 | import { showContextMenu } from './contextMenu'; 4 | import { join } from 'path'; 5 | import SessionManager from './SessionManager'; 6 | import showTopMenu from './showTopMenu'; 7 | import { randomUUID } from 'crypto'; 8 | import handleKeyboardShortcut from './handleKeyboardShortcut'; 9 | import Store from 'electron-store'; 10 | import FileHandler from './FileHandler'; 11 | import WindowFactory from './WindowFactory'; 12 | import setUpTabTransferring from './setUpTabTransferring'; 13 | import License from '../../LICENSE.md'; 14 | import Updater from './Updater'; 15 | import SettingStore from '../utils/SettingStore'; 16 | 17 | const initialFiles = process.argv.slice(app.isPackaged ? 1 : 2), 18 | gotLock = app.requestSingleInstanceLock(initialFiles); 19 | 20 | if (!gotLock) app.quit(); 21 | 22 | function emitSettingsUpdate() { 23 | BrowserWindow.getAllWindows().forEach(win => win.webContents.send('settings-updated')); 24 | } 25 | 26 | const store = new Store(), 27 | settings = new SettingStore(emitSettingsUpdate, store), 28 | fileHandler = new FileHandler(), 29 | windowFactory = new WindowFactory(store, fileHandler), 30 | updater = new Updater(), 31 | sessionManager = new SessionManager(); 32 | 33 | Menu.setApplicationMenu(Menu.buildFromTemplate([])); 34 | fileHandler.registerEvents(app); 35 | 36 | app.whenReady().then(() => windowFactory.create(initialFiles)); 37 | 38 | updater.check(); 39 | 40 | app.on('web-contents-created', (_, contents) => { 41 | if (contents.getType() !== 'webview') return; 42 | 43 | contents.on('context-menu', (_, params) => showContextMenu(params, contents.hostWebContents, contents)); 44 | }); 45 | 46 | ipcMain.on('renderer-settings-updated', () => { 47 | settings.markExternalSet(); 48 | emitSettingsUpdate(); 49 | }); 50 | 51 | ipcMain.on('show-license', () => fileHandler.openFiles(join(__dirname, License))); 52 | 53 | ipcMain.on('show-window-devtools', e => webContents.fromId(e.sender.id)!.openDevTools({ 54 | mode: 'undocked' 55 | })); 56 | 57 | ipcMain.handle('show-open-dialog', async (e) => { 58 | const browserWindow = BrowserWindow.fromWebContents(e.sender)!, 59 | openDialog = await dialog.showOpenDialog(browserWindow, { 60 | filters: getOpenFilters() 61 | }); 62 | 63 | return openDialog.filePaths; 64 | }); 65 | 66 | ipcMain.handle('get-webcontents-id', e => e.sender.id); 67 | 68 | ipcMain.on('crash-renderer', (_, webContentsId: number) => webContents.fromId(webContentsId)?.forcefullyCrashRenderer()); 69 | 70 | ipcMain.on('move-window', (e, x: number, y: number) => 71 | BrowserWindow.fromWebContents(e.sender)!.setPosition(x, y) 72 | ); 73 | 74 | ipcMain.on('web-dialog', (e, type, message, initial) => { 75 | const uuid = randomUUID(), 76 | onResponse = (_: Electron.IpcMainEvent, resUUID: string, res?: boolean | string) => { 77 | if (resUUID !== uuid) return; 78 | 79 | ipcMain.off('web-dialog-response', onResponse); 80 | e.returnValue = res; 81 | }; 82 | 83 | BrowserWindow.getFocusedWindow()?.webContents.send('web-dialog-request', uuid, type, message, initial); 84 | ipcMain.on('web-dialog-response', onResponse); 85 | }); 86 | 87 | ipcMain.on('set-devtool-webview', (_, targetContentsId: number, devtoolsContentsId: number) => { 88 | const target = webContents.fromId(targetContentsId)!, 89 | devtools = webContents.fromId(devtoolsContentsId)!; 90 | 91 | target.setDevToolsWebContents(devtools); 92 | target.openDevTools(); 93 | 94 | devtools.executeJavaScript('window.location.reload();'); 95 | }); 96 | 97 | ipcMain.on('perform-window-action', (e, action) => { 98 | const browserWindow = BrowserWindow.fromWebContents(e.sender)!; 99 | 100 | switch (action) { 101 | case 'minimize': 102 | browserWindow.minimize(); 103 | break; 104 | case 'maximize': 105 | browserWindow.maximize(); 106 | break; 107 | case 'unmaximize': 108 | browserWindow.unmaximize(); 109 | break; 110 | case 'close': 111 | browserWindow.close(); 112 | } 113 | }); 114 | 115 | ipcMain.handle('get-path', async (e, type: string, defaultPath: string) => { 116 | const browserWindow = BrowserWindow.fromWebContents(e.sender)!, 117 | pathInfo = await dialog.showSaveDialog(browserWindow, { 118 | defaultPath, 119 | filters: getSaveFilters(type) 120 | }); 121 | 122 | return pathInfo.filePath; 123 | }); 124 | 125 | settings.callAndListen('theme', (theme: 'system' | 'light' | 'dark') => { 126 | nativeTheme.themeSource = theme; 127 | }); 128 | 129 | ipcMain.on('set-session',(_, ...args: [string, string, string, string]) => sessionManager.set(...args)); 130 | ipcMain.on('delete-session', (_, partition: string) => sessionManager.delete(partition)); 131 | 132 | ipcMain.on('show-menu', showTopMenu); 133 | ipcMain.on('keyboard-input', handleKeyboardShortcut); 134 | 135 | setUpTabTransferring(windowFactory); -------------------------------------------------------------------------------- /src/main/WindowFactory.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Point, screen } from 'electron'; 2 | import { join } from 'path'; 3 | import { platform } from 'os'; 4 | import { showContextMenu } from './contextMenu'; 5 | import npmPackage from '../../package.json'; 6 | import Icon from '../icon/icon.png'; 7 | import IconIco from '../icon/icon.ico'; 8 | import Store from 'electron-store'; 9 | import FileHandler from './FileHandler'; 10 | 11 | type FullWindowState = { 12 | x: number, 13 | y: number, 14 | width: number, 15 | height: number, 16 | maximized?: boolean 17 | }; 18 | 19 | type WindowState = FullWindowState | { 20 | x: undefined, 21 | y: undefined, 22 | width: number, 23 | height: number, 24 | maximized?: boolean 25 | }; 26 | 27 | export default class WindowFactory { 28 | static DEFAULT_WIDTH = 800; 29 | static DEFAULT_HEIGHT = 600; 30 | static STATE_EVENTS = ['focus', 'moved', 'resize', 'maximize', 'unmaximize'] as const; 31 | 32 | constructor( 33 | private store: Store, 34 | private fileHandler: FileHandler 35 | ) { } 36 | 37 | stateWithinBounds(state: FullWindowState, bounds: Electron.Rectangle) { 38 | return state.x >= bounds.x && 39 | state.y >= bounds.y && 40 | state.x + state.width <= bounds.x + bounds.width && 41 | state.y + state.height <= bounds.y + bounds.height; 42 | } 43 | 44 | stateWithinDisplay(state: WindowState) { 45 | if (state.x === undefined) { 46 | const bounds = screen.getPrimaryDisplay().bounds; 47 | return state.width <= bounds.width && state.height <= bounds.height; 48 | } 49 | 50 | return screen.getAllDisplays().some( 51 | display => this.stateWithinBounds(state, display.bounds) 52 | ); 53 | } 54 | 55 | getWindowState(position?: Electron.Point) { 56 | const storedState = this.store.get('window-state', {}) as Partial; 57 | 58 | const state = { 59 | x: position?.x ?? storedState.x, 60 | y: position?.y ?? storedState.y, 61 | width: storedState.width ?? WindowFactory.DEFAULT_WIDTH, 62 | height: storedState.height ?? WindowFactory.DEFAULT_HEIGHT, 63 | maximized: storedState.maximized ?? false 64 | } as WindowState; 65 | 66 | if (position) { 67 | return state; 68 | } 69 | 70 | if (state.x !== undefined && !this.stateWithinDisplay(state)) { 71 | delete (state as WindowState).x; 72 | delete (state as WindowState).y; 73 | } 74 | 75 | if (!this.stateWithinDisplay(state)) { 76 | state.width = WindowFactory.DEFAULT_WIDTH; 77 | state.height = WindowFactory.DEFAULT_HEIGHT; 78 | } 79 | 80 | return state; 81 | } 82 | 83 | saveState(window: BrowserWindow) { 84 | const bounds = window.getNormalBounds(), 85 | state: WindowState = { 86 | ...bounds, 87 | maximized: window.isMaximized() 88 | }; 89 | 90 | this.store.set('window-state', state); 91 | } 92 | 93 | create(files: string[] = [], position?: Point) { 94 | const earlyFileQueue = this.fileHandler.consumeQueue(); 95 | 96 | if (earlyFileQueue?.length) { 97 | files.push(...earlyFileQueue); 98 | } 99 | 100 | const state = this.getWindowState(position), 101 | options: Electron.BrowserWindowConstructorOptions = { 102 | ...state, 103 | resizable: true, 104 | frame: false, 105 | title: npmPackage.build.productName, 106 | icon: join(__dirname, platform() === 'win32' ? IconIco : Icon), 107 | webPreferences: { 108 | webviewTag: true, 109 | nodeIntegration: true, 110 | // BUG: Required to bypass https://github.com/electron/electron/issues/22582 111 | nodeIntegrationInSubFrames: true, 112 | contextIsolation: false, 113 | additionalArguments: files.map(file => '--open-file=' + file) 114 | } 115 | }, 116 | win = new BrowserWindow(options); 117 | 118 | win.loadFile(join(__dirname, 'window.html')); 119 | 120 | if (state.maximized) win.maximize(); 121 | 122 | const listener = () => this.saveState(win); 123 | WindowFactory.STATE_EVENTS.forEach(event => win.on(event as 'focus', listener)); 124 | 125 | win.webContents.on('context-menu', (_, params) => showContextMenu(params, win.webContents)); 126 | 127 | win.webContents.once('ipc-message', () => { 128 | win.webContents.send(win.isMaximized() ? 'maximize' : 'unmaximize'); 129 | win.on('maximize', () => win.webContents.send('maximize')); 130 | win.on('unmaximize', () => win.webContents.send('unmaximize')); 131 | 132 | this.fileHandler.setCurrentWindow(win); 133 | }); 134 | 135 | return win; 136 | } 137 | } -------------------------------------------------------------------------------- /src/main/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import { clipboard, ContextMenuParams, Menu, MenuItemConstructorOptions, session, WebContents } from 'electron'; 2 | 3 | export function showContextMenu(params: ContextMenuParams, main: WebContents, webview?: WebContents) { 4 | const template: MenuItemConstructorOptions[] = [], 5 | focused = webview ?? main, 6 | hasSelection = params.selectionText.length > 0; 7 | 8 | if (webview) { 9 | template.push( 10 | { 11 | label: 'Back', 12 | enabled: webview.navigationHistory.canGoBack(), 13 | accelerator: 'Alt+Left', 14 | click: () => webview.navigationHistory.goBack() 15 | }, 16 | { 17 | label: 'Forward', 18 | enabled: webview.navigationHistory.canGoForward(), 19 | accelerator: 'Alt+Right', 20 | click: () => webview.navigationHistory.goForward() 21 | } 22 | ); 23 | } 24 | 25 | template.push({ 26 | label: 'Run', 27 | accelerator: 'CmdOrCtrl+R', 28 | click: () => main.send('menu-action', 'run') 29 | }) 30 | 31 | if (webview) { 32 | template.push( 33 | { 34 | type: 'separator' 35 | }, 36 | { 37 | label: 'Find...', 38 | accelerator: 'CmdOrCtrl+F', 39 | click: () => main.send('menu-action', 'find') 40 | }, 41 | { 42 | label: 'Zoom...', 43 | accelerator: 'CmdOrCtrl+=', 44 | click: () => main.send('menu-action', 'zoom') 45 | }, 46 | { 47 | label: 'Print...', 48 | accelerator: 'CmdOrCtrl+P', 49 | click: () => webview.print({ silent: false }) 50 | } 51 | ); 52 | } else { 53 | template.push({ 54 | label: 'Format', 55 | click: () => void focused.executeJavaScript('formatEditor()') 56 | }); 57 | } 58 | 59 | template.push({ 60 | type: 'separator' 61 | }); 62 | 63 | if (params.isEditable) { 64 | template.push( 65 | { 66 | label: 'Undo', 67 | accelerator: 'CmdOrCtrl+Z', 68 | click: () => focused.undo() 69 | }, 70 | { 71 | label: 'Redo', 72 | accelerator: 'CmdOrCtrl+Shift+Z ', 73 | click: () => focused.redo() 74 | }, 75 | { 76 | type: 'separator', 77 | } 78 | ); 79 | } 80 | 81 | if (params.isEditable) { 82 | template.push({ 83 | label: webview ? 'Cut' : 'Cut as text', 84 | accelerator: 'CmdOrCtrl+Z', 85 | visible: params.isEditable, 86 | enabled: hasSelection, 87 | click: () => focused.cut() 88 | }); 89 | } 90 | 91 | if (params.isEditable || hasSelection) { 92 | template.push({ 93 | label: webview ? 'Copy' : 'Copy as text', 94 | accelerator: 'CmdOrCtrl+C', 95 | enabled: hasSelection, 96 | click: () => focused.copy() 97 | }); 98 | } 99 | 100 | if (params.isEditable) { 101 | template.push({ 102 | label: webview ? 'Paste' : 'Paste text', 103 | accelerator: 'CmdOrCtrl+V', 104 | enabled: clipboard.availableFormats().includes('text/plain'), 105 | click: () => focused.paste() 106 | }); 107 | } 108 | 109 | if (!webview) { 110 | if (params.isEditable) { 111 | template.push( 112 | { 113 | type: 'separator' 114 | }, 115 | { 116 | label: 'Cut as HTML', 117 | enabled: hasSelection, 118 | click: () => void focused.executeJavaScript(`htmlClipboard.cut()`) 119 | } 120 | ); 121 | } 122 | 123 | if (params.isEditable || hasSelection) { 124 | template.push({ 125 | label: 'Copy as HTML', 126 | enabled: hasSelection, 127 | click: () => void focused.executeJavaScript(`htmlClipboard.copy()`) 128 | }); 129 | } 130 | 131 | if (params.isEditable) { 132 | template.push( 133 | { 134 | label: 'Paste HTML', 135 | enabled: clipboard.availableFormats().includes('text/html'), 136 | click: () => void focused.executeJavaScript(`htmlClipboard.paste()`) 137 | }, 138 | { 139 | type: 'separator' 140 | } 141 | ); 142 | } 143 | } 144 | 145 | if (params.isEditable || hasSelection) { 146 | template.push( 147 | { 148 | label: 'Select All', 149 | accelerator: 'CmdOrCtrl+A', 150 | click: () => focused.selectAll() 151 | }, 152 | { 153 | type: 'separator' 154 | } 155 | ) 156 | } 157 | 158 | if (webview) { 159 | let mediaType; 160 | 161 | switch (params.mediaType) { 162 | case 'image': 163 | case 'canvas': 164 | mediaType = 'Image'; 165 | break; 166 | case 'video': 167 | mediaType = 'Video'; 168 | break; 169 | } 170 | 171 | if (params.linkURL.length > 0) { 172 | template.push( 173 | { 174 | label: 'Copy Link Address', 175 | visible: params.linkURL.length > 0, 176 | click: () => clipboard.write({ text: params.linkURL }) 177 | }, 178 | { 179 | type: 'separator', 180 | visible: params.linkURL.length > 0, 181 | } 182 | ); 183 | } 184 | 185 | if (mediaType) { 186 | template.push( 187 | { 188 | label: `Save ${mediaType}`, 189 | click: () => session.defaultSession.downloadURL(params.srcURL) 190 | }, 191 | { 192 | label: `Copy ${mediaType}`, 193 | click: () => webview.copyImageAt(params.x, params.y) 194 | }, 195 | { 196 | label: `Copy ${mediaType} Address`, 197 | click: () => clipboard.write({ text: params.srcURL }) 198 | }, 199 | { 200 | type: 'separator' 201 | } 202 | ); 203 | } 204 | 205 | template.push({ 206 | label: 'Inspect Element', 207 | click: () => { 208 | webview.inspectElement(params.x, params.y); 209 | main.send('menu-action', 'devtools'); 210 | } 211 | }); 212 | } 213 | 214 | Menu.buildFromTemplate(template).popup({ 215 | x: params.x, 216 | y: params.y 217 | }); 218 | } -------------------------------------------------------------------------------- /src/window/window.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 | 136 | -------------------------------------------------------------------------------- /src/window/window.ts: -------------------------------------------------------------------------------- 1 | import './less/window.less'; 2 | 3 | import ace from 'ace-builds'; 4 | import 'ace-builds/src-noconflict/ext-language_tools'; 5 | import 'ace-builds/src-noconflict/ext-searchbox'; 6 | import 'ace-builds/src-noconflict/mode-html'; 7 | import 'ace-builds/src-noconflict/mode-markdown'; 8 | import 'ace-builds/src-noconflict/mode-svg'; 9 | import 'ace-builds/src-noconflict/theme-clouds'; 10 | import 'ace-builds/src-noconflict/theme-clouds_midnight'; 11 | import 'ace-builds/src-noconflict/snippets/html'; 12 | 13 | import { ipcRenderer } from 'electron'; 14 | import Tabs from './Tabs'; 15 | import SettingStore from '../utils/SettingStore'; 16 | import SplitElement from './SplitElement'; 17 | import promptUnsaved from './popups/promptUnsaved'; 18 | import { getEditorOptions, initializeSettings } from './userOptions'; 19 | import Icon from '../icon/icon.ico'; 20 | import WebDialogFactory from './popups/WebDialogFactory'; 21 | import MenuActionProcessor from './MenuActionProcessor'; 22 | import UpdateUI from './UpdateUI'; 23 | import ThemeMode from './ThemeMode'; 24 | import HTMLClipboard from './HTMLClipboard'; 25 | import format from './format'; 26 | 27 | declare global { 28 | interface Window { 29 | htmlClipboard: HTMLClipboard; 30 | formatEditor: () => Promise; 31 | } 32 | } 33 | 34 | const openFilePrefix = '--open-file=', 35 | openFiles = process.argv 36 | .filter(arg => arg.startsWith(openFilePrefix)) 37 | .map(arg => arg.substring(openFilePrefix.length)), 38 | webContentsIdPromise = ipcRenderer.invoke('get-webcontents-id'), 39 | settings = new SettingStore(() => ipcRenderer.send('renderer-settings-updated')), 40 | themeMode = new ThemeMode(), 41 | editor = ace.edit( 42 | document.getElementById('editor-container'), 43 | getEditorOptions(settings, themeMode) 44 | ), 45 | tabs = new Tabs( 46 | document.getElementById('tabs')!, 47 | document.getElementById('webview-container')!, 48 | document.getElementById('devtool-container')!, 49 | document.getElementById('switcher')!, 50 | editor, 51 | webContentsIdPromise, 52 | settings, 53 | themeMode 54 | ), 55 | mainSplit = new SplitElement( 56 | 'editor', 57 | document.getElementById('main-container')!, 58 | settings.get('editorDirection'), 59 | openFiles.length == 0 || settings.get('autoEdit'), 60 | settings.get('editorWidth'), 61 | settings.get('editorHeight') 62 | ), 63 | viewerSplit = new SplitElement( 64 | 'devtools', 65 | document.getElementById('viewer-container')!, 66 | settings.get('devtoolsDirection'), 67 | settings.get('autoDevtools'), 68 | settings.get('viewerWidth'), 69 | settings.get('viewerHeight') 70 | ), 71 | switcherSplit = new SplitElement( 72 | 'switcher', 73 | document.getElementById('switcher-container')!, 74 | 'horizontal', 75 | settings.get('autoSwitcher'), 76 | settings.get('switcherWidth'), 77 | '100%', 78 | true 79 | ), 80 | updateUI = new UpdateUI(); 81 | 82 | mainSplit.on('width', x => settings.set('editorWidth', x)); 83 | mainSplit.on('height', x => settings.set('editorHeight', x)); 84 | mainSplit.on('visible', () => editor.resize()); 85 | 86 | viewerSplit.on('width', x => settings.set('viewerWidth', x)); 87 | viewerSplit.on('height', x => settings.set('viewerHeight', x)); 88 | 89 | switcherSplit.on('width', x => settings.set('switcherWidth', x)); 90 | 91 | document.getElementById('switch')!.addEventListener('click', () => switcherSplit.toggleVisible()); 92 | document.getElementById('edit')!.addEventListener('click', () => mainSplit.toggleVisible()); 93 | document.getElementById('inspect')!.addEventListener('click', () => viewerSplit.toggleVisible()); 94 | document.getElementById('run')!.addEventListener('click', () => tabs.currentTab.preview()); 95 | document.getElementById('header')!.addEventListener('contextmenu', e => e.preventDefault()); 96 | 97 | (document.getElementById('top-icon') as HTMLImageElement).src = Icon; 98 | 99 | ['options', 'new'].forEach(id => document.getElementById(id)!.addEventListener('click', () => { 100 | const rect = document.getElementById(id)!.getBoundingClientRect(); 101 | 102 | ipcRenderer.send('show-menu', id, Math.round(rect.x), Math.round(rect.y + rect.height), tabs.currentTab.mode); 103 | })); 104 | 105 | ['minimize', 'maximize', 'unmaximize', 'close'].forEach(windowAction => { 106 | document.getElementById(windowAction)!.addEventListener('click', () => ipcRenderer.send('perform-window-action', windowAction)); 107 | }); 108 | 109 | initializeSettings(settings, themeMode, editor, tabs); 110 | 111 | if (openFiles.length) { 112 | for (const file of openFiles) { 113 | tabs.createFromFile(file); 114 | } 115 | } else { 116 | tabs.createTab(); 117 | } 118 | 119 | window.htmlClipboard = new HTMLClipboard(editor); 120 | window.formatEditor = () => format(editor, tabs.currentTab.mode, settings); 121 | 122 | let forceClose = false; 123 | window.addEventListener('beforeunload', async e => { 124 | if (forceClose || !tabs.tabs.some(tab => tab.unsaved)) return; 125 | 126 | e.preventDefault(); 127 | 128 | if (await promptUnsaved(tabs, settings)) { 129 | forceClose = true; 130 | ipcRenderer.send('perform-window-action', 'close'); 131 | } 132 | }); 133 | 134 | document.body.addEventListener('keyup', e => ipcRenderer.send( 135 | 'keyboard-input', 136 | true, 137 | e.key, 138 | process.platform === 'darwin' ? e.metaKey : e.ctrlKey, 139 | e.altKey, 140 | e.shiftKey 141 | )); 142 | 143 | ipcRenderer.on('settings-updated', () => settings.markExternalSet()); 144 | 145 | ipcRenderer.on('release-tab-to', (_, localTabId: string, targetId: number, targetIndex?: number) => { 146 | const tab = tabs.getTabById(localTabId); 147 | 148 | if (!tab) return; 149 | 150 | ipcRenderer.send('send-tab-to', targetId, tab.getTabData(), targetIndex); 151 | tabs.removeTab(tab); 152 | }); 153 | 154 | ipcRenderer.on('open-files', (_, files: string[]) => files.forEach(file => tabs.createFromFile(file))); 155 | ipcRenderer.on('show-tab', (_, tabData: TabData, index?: number) => tabs.createTab(tabData, index)); 156 | 157 | ipcRenderer.on('maximize', () => document.body.classList.add('maximized')); 158 | ipcRenderer.on('unmaximize', () => document.body.classList.remove('maximized')); 159 | 160 | const webDialogFactory = new WebDialogFactory(tabs), 161 | menuActionProcessor = new MenuActionProcessor(tabs, mainSplit, viewerSplit, settings); 162 | 163 | ipcRenderer.on('web-dialog-request', webDialogFactory.processRequest.bind(webDialogFactory)); 164 | ipcRenderer.on('menu-action', menuActionProcessor.processRequest.bind(menuActionProcessor)); 165 | 166 | updateUI.init(); 167 | 168 | ipcRenderer.send('ipc-message'); -------------------------------------------------------------------------------- /src/window/Tab.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import { join, extname, basename, dirname } from 'path'; 3 | import { ipcRenderer, webUtils } from 'electron'; 4 | import Tabs from './Tabs'; 5 | import { getDefaultExtension, getFileType } from '../utils/fileTypes'; 6 | import ace, { Ace } from 'ace-builds'; 7 | import throttle from 'lodash.throttle'; 8 | import { pathToFileURL } from 'url'; 9 | import { popup } from './popups/popup'; 10 | import { emittedOnce } from '../utils/emittedOnce'; 11 | import { useSVG } from './useSVG'; 12 | import MiniPopupFactory from './popups/MiniPopupFactory'; 13 | import { getSessionOptions } from './userOptions'; 14 | import promptUnsaved from './popups/promptUnsaved'; 15 | import FileSwitcher from './FileSwitcher'; 16 | 17 | export default class Tab { 18 | webview = document.createElement('webview'); 19 | devtools = document.createElement('webview'); 20 | switcher = new FileSwitcher(this); 21 | webviewSubContainer = document.createElement('div'); 22 | partition = crypto.randomUUID(); 23 | faviconElement = document.createElement('img'); 24 | webviewReady = emittedOnce(this.webview, 'dom-ready'); 25 | devtoolsReady = emittedOnce(this.devtools, 'dom-ready'); 26 | tabElement = document.createElement('div'); 27 | titleElement = document.createElement('span'); 28 | closeButton = document.createElement('button'); 29 | unsavedIndicator = document.createElement('div'); 30 | onThemeChange: () => void; 31 | removeCSSUpdateListener?: () => void; 32 | 33 | editorSession: Ace.EditSession; 34 | tabStore: Tabs; 35 | mode: string; 36 | path: string; 37 | watchController?: AbortController; 38 | tabId: string; 39 | dragStart?: [number, number]; 40 | savedText: string; 41 | miniPopupFactory: MiniPopupFactory; 42 | webviewCssId: string | undefined; 43 | autoSave: () => void; 44 | 45 | constructor(tabStore: Tabs, tabId: string, data: TabData = {}) { 46 | this.tabStore = tabStore; 47 | this.tabId = tabId; 48 | 49 | this.webview.src = 'about:blank'; 50 | this.webview.partition = this.partition; 51 | this.webview.preload = join(__dirname, 'preload.js'); 52 | this.webview.webpreferences = 'transparent=no,nodeIntegrationInSubFrames'; 53 | 54 | // Can be placed in preload if this breaks 55 | this.webview.addEventListener('did-navigate', () => { 56 | delete this.webviewCssId; 57 | this.updateWebviewCSS(); 58 | }); 59 | 60 | this.onThemeChange = () => void this.updateWebviewCSS(); 61 | this.tabStore.themeMode.on('change', this.onThemeChange); 62 | 63 | this.removeCSSUpdateListener = this.tabStore.settings.listen( 64 | 'viewerUseTheme', 65 | this.onThemeChange 66 | ); 67 | 68 | this.webview.addEventListener('did-finish-load', () => { 69 | this.updateTitle(); 70 | this.faviconElement.removeAttribute('src'); 71 | }); 72 | 73 | this.webview.addEventListener('page-title-updated', () => this.updateTitle()); 74 | this.webview.addEventListener('page-favicon-updated', e => this.faviconElement.src = e.favicons[0]); 75 | 76 | this.webviewSubContainer.append(this.webview); 77 | this.webviewSubContainer.classList.add('webview-sub-container', 'show-when-current'); 78 | tabStore.addToMainArea(this.webviewSubContainer); 79 | 80 | this.devtools.src = 'about:blank'; 81 | this.devtools.classList.add('show-when-current'); 82 | tabStore.addToDevtoolsArea(this.devtools); 83 | 84 | tabStore.addToSwitcherArea(this.switcher.el); 85 | 86 | this.faviconElement.classList.add('tab-favicon'); 87 | 88 | this.mode = data.mode ?? this.tabStore.settings.get('defaultType'); 89 | this.savedText = data.savedText ?? ''; 90 | this.miniPopupFactory = new MiniPopupFactory(this); 91 | 92 | this.autoSave = throttle(async () => this.save(SaveType.Auto), 500); 93 | 94 | this.editorSession = ace.createEditSession(data.text ?? '', `ace/mode/${this.mode}` as unknown as Ace.SyntaxMode); 95 | this.editorSession.setOptions(getSessionOptions(this.tabStore.settings)); 96 | 97 | let microtaskQueued = false; 98 | this.editorSession.on('change', () => { 99 | // Aggregate all updates from this JS execution stack run 100 | if (microtaskQueued) return; 101 | 102 | queueMicrotask(() => { 103 | this.updateUnsaved(); 104 | 105 | if (this.tabStore.settings.get('autoRun')) 106 | this.preview(); 107 | 108 | if (this.tabStore.settings.get('autoSave')) 109 | this.autoSave(); 110 | 111 | microtaskQueued = false; 112 | }); 113 | 114 | microtaskQueued = true; 115 | }); 116 | 117 | this.unsavedIndicator.append(useSVG('circle')); 118 | this.unsavedIndicator.classList.add('tab-unsaved'); 119 | 120 | this.updateTitle(); 121 | this.titleElement.classList.add('tab-title'); 122 | 123 | this.closeButton.append(useSVG('x')); 124 | this.closeButton.classList.add('tab-close'); 125 | this.closeButton.title = 'Close Tab (Ctrl+W)' 126 | this.closeButton.addEventListener('click', e => { 127 | e.stopPropagation(); 128 | 129 | this.close(); 130 | }); 131 | 132 | this.tabElement.draggable = true; 133 | this.tabElement.classList.add('tab'); 134 | this.tabElement.addEventListener('click', () => tabStore.selectTab(this)); 135 | this.tabElement.append(this.unsavedIndicator, this.faviconElement, this.titleElement, this.closeButton); 136 | 137 | this.setPath(data.path, true); 138 | this.addDragAndDrop(); 139 | this.linkDevtools(); 140 | this.preview(data.text); 141 | this.updateUnsaved(); 142 | } 143 | 144 | get unsaved() { 145 | return this.editorSession.getValue() !== this.savedText; 146 | } 147 | 148 | get defaultName() { 149 | let suffix = getDefaultExtension(this.mode) ?? ''; 150 | 151 | if (suffix) suffix = '.' + suffix; 152 | 153 | return `unnamed${suffix}`; 154 | } 155 | 156 | async updateWebviewCSS() { 157 | const darkViewer = this.tabStore.themeMode.darkMode, 158 | oldCssId = this.webviewCssId; 159 | 160 | if (this.tabStore.settings.get('viewerUseTheme')) { 161 | this.webviewCssId = await this.webview.insertCSS(`:root{color-scheme:${darkViewer ? 'dark' : 'light'}}`); 162 | } 163 | 164 | if (oldCssId) this.webview.removeInsertedCSS(oldCssId); 165 | } 166 | 167 | addDragAndDrop(): void { 168 | this.tabElement.addEventListener('dragover', e => e.preventDefault()); 169 | 170 | this.tabElement.addEventListener('dragstart', e => { 171 | this.dragStart = [e.offsetX, e.offsetY]; 172 | e.dataTransfer!.setData('burrow-html-markdown/tab-id', this.tabId); 173 | }); 174 | 175 | this.tabElement.addEventListener('dragend', e => { 176 | const x = Math.round(screenX + e.x - this.tabStore.baseRowX - this.dragStart![0]), 177 | y = Math.round(screenY + e.y - this.dragStart![1]); 178 | 179 | delete this.dragStart; 180 | 181 | if (e.dataTransfer!.dropEffect !== 'none') return; 182 | 183 | if (this.tabStore.tabs.length > 1) { 184 | ipcRenderer.send('new-window-with-tab', this.tabId, x, y); 185 | return; 186 | } 187 | 188 | ipcRenderer.send('move-window', x, y); 189 | }) 190 | 191 | this.tabElement.addEventListener('drop', e => { 192 | e.preventDefault(); 193 | e.stopPropagation(); 194 | 195 | const boundRect = this.tabElement.getBoundingClientRect(); 196 | let targetIndex = this.tabStore.getTabIndex(this); 197 | 198 | if (e.pageX - boundRect.left > boundRect.width / 2) targetIndex++; 199 | 200 | if (e.dataTransfer?.files.length) { 201 | [...e.dataTransfer.files].forEach(file => 202 | this.tabStore.createFromFile(webUtils.getPathForFile(file), targetIndex) 203 | ); 204 | 205 | return; 206 | } 207 | 208 | const tabId = e.dataTransfer?.getData('burrow-html-markdown/tab-id'); 209 | 210 | if (!tabId || tabId === this.tabId) return; 211 | 212 | const localTab = this.tabStore.getTabById(tabId); 213 | 214 | if (localTab) { 215 | this.tabStore.moveTab(localTab, targetIndex); 216 | return; 217 | } 218 | 219 | ipcRenderer.send('request-tab', tabId, targetIndex); 220 | }); 221 | } 222 | 223 | updateTitle() { 224 | let title 225 | 226 | try { 227 | title = this.webview.getTitle(); 228 | } catch (_) { 229 | // Pass 230 | } 231 | 232 | if (!title || title === this.partition || title === 'about:blank') { 233 | title = this.path ? basename(this.path) : this.defaultName; 234 | } 235 | 236 | const unsavedText = this.unsaved ? ' - Unsaved' : '', 237 | pathText = this.path ? ` - ${this.path}` : ''; 238 | 239 | this.titleElement.innerText = `${title}`; 240 | this.titleElement.title = `${title}${pathText}${unsavedText}`; 241 | } 242 | 243 | async preview(text?: string): Promise { 244 | const value = text ?? this.editorSession.getValue(); 245 | 246 | await this.webviewReady; 247 | 248 | ipcRenderer.send('set-session', this.partition, this.path, this.mode, value); 249 | 250 | try { 251 | await this.webview.loadURL(this.path ? pathToFileURL(this.path).href : `file://${this.partition}/`); 252 | this.webview.clearHistory(); 253 | } catch (_) { 254 | // Pass 255 | } 256 | } 257 | 258 | async linkDevtools() { 259 | await this.webviewReady; 260 | await this.devtoolsReady; 261 | 262 | ipcRenderer.send( 263 | 'set-devtool-webview', 264 | this.webview.getWebContentsId(), 265 | this.devtools.getWebContentsId() 266 | ); 267 | } 268 | 269 | async save(saveType = SaveType.Standard): Promise { 270 | const value = this.editorSession.getValue(); 271 | 272 | if ( 273 | saveType === SaveType.Auto && value === this.savedText 274 | ) return true; 275 | 276 | if ( 277 | saveType === SaveType.SetName || 278 | (saveType === SaveType.Standard && !this.path) 279 | ) await this.getPath(); 280 | 281 | if (!this.path) return false; 282 | 283 | try { 284 | await fs.writeFile(this.path, value); 285 | } catch (_) { 286 | if (saveType === SaveType.Auto) return false; 287 | 288 | return new Promise(resolve => { 289 | const retry = () => resolve(this.save(SaveType.Standard)); 290 | 291 | popup( 292 | 'Failed To Save', 293 | 'Failed to save tab to ' + this.path, 294 | [ 295 | { 296 | text: 'Retry', 297 | click: retry 298 | }, 299 | { 300 | text: 'Save As...', 301 | click: () => resolve(this.save(SaveType.SetName)) 302 | }, 303 | { 304 | text: 'Cancel' 305 | } 306 | ], 307 | undefined, 308 | false, 309 | () => resolve(false), 310 | retry 311 | ); 312 | }); 313 | } 314 | 315 | this.savedText = value; 316 | this.updateUnsaved(); 317 | 318 | return true; 319 | } 320 | 321 | async getPath(): Promise { 322 | const newPath = await ipcRenderer.invoke('get-path', this.mode, this.path || this.defaultName); 323 | 324 | this.setPath(newPath); 325 | } 326 | 327 | async setPath(path?: string, loadFile = false, keepFolder = false): Promise { 328 | if (!path || path === this.path) return; 329 | if (loadFile && !(await promptUnsaved(this, this.tabStore.settings))) return; 330 | 331 | if (!getFileType(path)) { 332 | popup('Failed to set path', `Unsupported file type ${extname(path)}`); 333 | return; 334 | } 335 | 336 | this.path = path; 337 | this.mode = getFileType(this.path)!; 338 | 339 | if (!keepFolder) { 340 | this.switcher.folder = dirname(this.path); 341 | } 342 | 343 | this.updateTitle(); 344 | this.tabStore.updateNoPathAttribute(); 345 | 346 | this.editorSession.setMode(`ace/mode/${this.mode}`); 347 | 348 | if (loadFile) { 349 | const loadedText = await fs.readFile(this.path, 'utf8'); 350 | 351 | this.editorSession.setValue(loadedText); 352 | this.preview(loadedText); 353 | this.savedText = loadedText; 354 | } else { 355 | await fs.writeFile(this.path, this.editorSession.getValue()); 356 | this.savedText = this.editorSession.getValue(); 357 | } 358 | 359 | this.updateUnsaved(); 360 | this.watchController?.abort(); 361 | this.watchController = new AbortController(); 362 | 363 | const watcher = fs.watch(this.path, { 364 | signal: this.watchController.signal 365 | }); 366 | 367 | try { 368 | for await (const _ of watcher) { 369 | const newValue = await fs.readFile(this.path, 'utf8'); 370 | 371 | if (newValue !== this.savedText) { 372 | const wasSaved = !this.unsaved; 373 | 374 | this.savedText = newValue; 375 | this.updateUnsaved(); 376 | 377 | if (wasSaved) this.editorSession.setValue(newValue); 378 | } 379 | } 380 | } catch (err) { 381 | if (err.name !== 'AbortError') throw err; 382 | } 383 | } 384 | 385 | async close() { 386 | if (await promptUnsaved(this, this.tabStore.settings)) 387 | this.tabStore.removeTab(this); 388 | } 389 | 390 | dispose(): void { 391 | this.watchController?.abort(); 392 | this.switcher.dispose(); 393 | this.tabElement.remove(); 394 | this.webviewSubContainer.remove(); 395 | this.devtools.remove(); 396 | this.tabStore.themeMode.removeListener('change', this.onThemeChange); 397 | this.removeCSSUpdateListener?.(); 398 | ipcRenderer.send('delete-session', this.partition); 399 | } 400 | 401 | getTabData(): TabData { 402 | return { 403 | mode: this.mode, 404 | path: this.path, 405 | text: this.editorSession.getValue(), 406 | savedText: this.savedText 407 | }; 408 | } 409 | 410 | updateUnsaved(): void { 411 | if (this.unsaved) { 412 | if (!this.tabStore.settings.get('autoSave') || !this.path) this.tabElement.classList.add('unsaved'); 413 | } else { 414 | this.tabElement.classList.remove('unsaved'); 415 | } 416 | 417 | this.updateTitle(); 418 | } 419 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # GNU LESSER GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | This version of the GNU Lesser General Public License incorporates the 12 | terms and conditions of version 3 of the GNU General Public License, 13 | supplemented by the additional permissions listed below. 14 | 15 | ## 0. Additional Definitions. 16 | 17 | As used herein, "this License" refers to version 3 of the GNU Lesser 18 | General Public License, and the "GNU GPL" refers to version 3 of the 19 | GNU General Public License. 20 | 21 | "The Library" refers to a covered work governed by this License, other 22 | than an Application or a Combined Work as defined below. 23 | 24 | An "Application" is any work that makes use of an interface provided 25 | by the Library, but which is not otherwise based on the Library. 26 | Defining a subclass of a class defined by the Library is deemed a mode 27 | of using an interface provided by the Library. 28 | 29 | A "Combined Work" is a work produced by combining or linking an 30 | Application with the Library. The particular version of the Library 31 | with which the Combined Work was made is also called the "Linked 32 | Version". 33 | 34 | The "Minimal Corresponding Source" for a Combined Work means the 35 | Corresponding Source for the Combined Work, excluding any source code 36 | for portions of the Combined Work that, considered in isolation, are 37 | based on the Application, and not on the Linked Version. 38 | 39 | The "Corresponding Application Code" for a Combined Work means the 40 | object code and/or source code for the Application, including any data 41 | and utility programs needed for reproducing the Combined Work from the 42 | Application, but excluding the System Libraries of the Combined Work. 43 | 44 | ## 1. Exception to Section 3 of the GNU GPL. 45 | 46 | You may convey a covered work under sections 3 and 4 of this License 47 | without being bound by section 3 of the GNU GPL. 48 | 49 | ## 2. Conveying Modified Versions. 50 | 51 | If you modify a copy of the Library, and, in your modifications, a 52 | facility refers to a function or data to be supplied by an Application 53 | that uses the facility (other than as an argument passed when the 54 | facility is invoked), then you may convey a copy of the modified 55 | version: 56 | 57 | - a) under this License, provided that you make a good faith effort 58 | to ensure that, in the event an Application does not supply the 59 | function or data, the facility still operates, and performs 60 | whatever part of its purpose remains meaningful, or 61 | - b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | ## 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from a 67 | header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | - a) Give prominent notice with each copy of the object code that 74 | the Library is used in it and that the Library and its use are 75 | covered by this License. 76 | - b) Accompany the object code with a copy of the GNU GPL and this 77 | license document. 78 | 79 | ## 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, taken 82 | together, effectively do not restrict modification of the portions of 83 | the Library contained in the Combined Work and reverse engineering for 84 | debugging such modifications, if you also do each of the following: 85 | 86 | - a) Give prominent notice with each copy of the Combined Work that 87 | the Library is used in it and that the Library and its use are 88 | covered by this License. 89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this 90 | license document. 91 | - c) For a Combined Work that displays copyright notices during 92 | execution, include the copyright notice for the Library among 93 | these notices, as well as a reference directing the user to the 94 | copies of the GNU GPL and this license document. 95 | - d) Do one of the following: 96 | - 0) Convey the Minimal Corresponding Source under the terms of 97 | this License, and the Corresponding Application Code in a form 98 | suitable for, and under terms that permit, the user to 99 | recombine or relink the Application with a modified version of 100 | the Linked Version to produce a modified Combined Work, in the 101 | manner specified by section 6 of the GNU GPL for conveying 102 | Corresponding Source. 103 | - 1) Use a suitable shared library mechanism for linking with 104 | the Library. A suitable mechanism is one that (a) uses at run 105 | time a copy of the Library already present on the user's 106 | computer system, and (b) will operate properly with a modified 107 | version of the Library that is interface-compatible with the 108 | Linked Version. 109 | - e) Provide Installation Information, but only if you would 110 | otherwise be required to provide such information under section 6 111 | of the GNU GPL, and only to the extent that such information is 112 | necessary to install and execute a modified version of the 113 | Combined Work produced by recombining or relinking the Application 114 | with a modified version of the Linked Version. (If you use option 115 | 4d0, the Installation Information must accompany the Minimal 116 | Corresponding Source and Corresponding Application Code. If you 117 | use option 4d1, you must provide the Installation Information in 118 | the manner specified by section 6 of the GNU GPL for conveying 119 | Corresponding Source.) 120 | 121 | ## 5. Combined Libraries. 122 | 123 | You may place library facilities that are a work based on the Library 124 | side by side in a single library together with other library 125 | facilities that are not Applications and are not covered by this 126 | License, and convey such a combined library under terms of your 127 | choice, if you do both of the following: 128 | 129 | - a) Accompany the combined library with a copy of the same work 130 | based on the Library, uncombined with any other library 131 | facilities, conveyed under the terms of this License. 132 | - b) Give prominent notice with the combined library that part of it 133 | is a work based on the Library, and explaining where to find the 134 | accompanying uncombined form of the same work. 135 | 136 | ## 6. Revised Versions of the GNU Lesser General Public License. 137 | 138 | The Free Software Foundation may publish revised and/or new versions 139 | of the GNU Lesser General Public License from time to time. Such new 140 | versions will be similar in spirit to the present version, but may 141 | differ in detail to address new problems or concerns. 142 | 143 | Each version is given a distinguishing version number. If the Library 144 | as you received it specifies that a certain numbered version of the 145 | GNU Lesser General Public License "or any later version" applies to 146 | it, you have the option of following the terms and conditions either 147 | of that published version or of any later version published by the 148 | Free Software Foundation. If the Library as you received it does not 149 | specify a version number of the GNU Lesser General Public License, you 150 | may choose any version of the GNU Lesser General Public License ever 151 | published by the Free Software Foundation. 152 | 153 | If the Library as you received it specifies that a proxy can decide 154 | whether future versions of the GNU Lesser General Public License shall 155 | apply, that proxy's public statement of acceptance of any version is 156 | permanent authorization for you to choose that version for the 157 | Library. 158 | 159 | # GNU GENERAL PUBLIC LICENSE 160 | 161 | Version 3, 29 June 2007 162 | 163 | Copyright (C) 2007 Free Software Foundation, Inc. 164 | 165 | 166 | Everyone is permitted to copy and distribute verbatim copies of this 167 | license document, but changing it is not allowed. 168 | 169 | ## Preamble 170 | 171 | The GNU General Public License is a free, copyleft license for 172 | software and other kinds of works. 173 | 174 | The licenses for most software and other practical works are designed 175 | to take away your freedom to share and change the works. By contrast, 176 | the GNU General Public License is intended to guarantee your freedom 177 | to share and change all versions of a program--to make sure it remains 178 | free software for all its users. We, the Free Software Foundation, use 179 | the GNU General Public License for most of our software; it applies 180 | also to any other work released this way by its authors. You can apply 181 | it to your programs, too. 182 | 183 | When we speak of free software, we are referring to freedom, not 184 | price. Our General Public Licenses are designed to make sure that you 185 | have the freedom to distribute copies of free software (and charge for 186 | them if you wish), that you receive source code or can get it if you 187 | want it, that you can change the software or use pieces of it in new 188 | free programs, and that you know you can do these things. 189 | 190 | To protect your rights, we need to prevent others from denying you 191 | these rights or asking you to surrender the rights. Therefore, you 192 | have certain responsibilities if you distribute copies of the 193 | software, or if you modify it: responsibilities to respect the freedom 194 | of others. 195 | 196 | For example, if you distribute copies of such a program, whether 197 | gratis or for a fee, you must pass on to the recipients the same 198 | freedoms that you received. You must make sure that they, too, receive 199 | or can get the source code. And you must show them these terms so they 200 | know their rights. 201 | 202 | Developers that use the GNU GPL protect your rights with two steps: 203 | (1) assert copyright on the software, and (2) offer you this License 204 | giving you legal permission to copy, distribute and/or modify it. 205 | 206 | For the developers' and authors' protection, the GPL clearly explains 207 | that there is no warranty for this free software. For both users' and 208 | authors' sake, the GPL requires that modified versions be marked as 209 | changed, so that their problems will not be attributed erroneously to 210 | authors of previous versions. 211 | 212 | Some devices are designed to deny users access to install or run 213 | modified versions of the software inside them, although the 214 | manufacturer can do so. This is fundamentally incompatible with the 215 | aim of protecting users' freedom to change the software. The 216 | systematic pattern of such abuse occurs in the area of products for 217 | individuals to use, which is precisely where it is most unacceptable. 218 | Therefore, we have designed this version of the GPL to prohibit the 219 | practice for those products. If such problems arise substantially in 220 | other domains, we stand ready to extend this provision to those 221 | domains in future versions of the GPL, as needed to protect the 222 | freedom of users. 223 | 224 | Finally, every program is threatened constantly by software patents. 225 | States should not allow patents to restrict development and use of 226 | software on general-purpose computers, but in those that do, we wish 227 | to avoid the special danger that patents applied to a free program 228 | could make it effectively proprietary. To prevent this, the GPL 229 | assures that patents cannot be used to render the program non-free. 230 | 231 | The precise terms and conditions for copying, distribution and 232 | modification follow. 233 | 234 | ## TERMS AND CONDITIONS 235 | 236 | ### 0. Definitions. 237 | 238 | "This License" refers to version 3 of the GNU General Public License. 239 | 240 | "Copyright" also means copyright-like laws that apply to other kinds 241 | of works, such as semiconductor masks. 242 | 243 | "The Program" refers to any copyrightable work licensed under this 244 | License. Each licensee is addressed as "you". "Licensees" and 245 | "recipients" may be individuals or organizations. 246 | 247 | To "modify" a work means to copy from or adapt all or part of the work 248 | in a fashion requiring copyright permission, other than the making of 249 | an exact copy. The resulting work is called a "modified version" of 250 | the earlier work or a work "based on" the earlier work. 251 | 252 | A "covered work" means either the unmodified Program or a work based 253 | on the Program. 254 | 255 | To "propagate" a work means to do anything with it that, without 256 | permission, would make you directly or secondarily liable for 257 | infringement under applicable copyright law, except executing it on a 258 | computer or modifying a private copy. Propagation includes copying, 259 | distribution (with or without modification), making available to the 260 | public, and in some countries other activities as well. 261 | 262 | To "convey" a work means any kind of propagation that enables other 263 | parties to make or receive copies. Mere interaction with a user 264 | through a computer network, with no transfer of a copy, is not 265 | conveying. 266 | 267 | An interactive user interface displays "Appropriate Legal Notices" to 268 | the extent that it includes a convenient and prominently visible 269 | feature that (1) displays an appropriate copyright notice, and (2) 270 | tells the user that there is no warranty for the work (except to the 271 | extent that warranties are provided), that licensees may convey the 272 | work under this License, and how to view a copy of this License. If 273 | the interface presents a list of user commands or options, such as a 274 | menu, a prominent item in the list meets this criterion. 275 | 276 | ### 1. Source Code. 277 | 278 | The "source code" for a work means the preferred form of the work for 279 | making modifications to it. "Object code" means any non-source form of 280 | a work. 281 | 282 | A "Standard Interface" means an interface that either is an official 283 | standard defined by a recognized standards body, or, in the case of 284 | interfaces specified for a particular programming language, one that 285 | is widely used among developers working in that language. 286 | 287 | The "System Libraries" of an executable work include anything, other 288 | than the work as a whole, that (a) is included in the normal form of 289 | packaging a Major Component, but which is not part of that Major 290 | Component, and (b) serves only to enable use of the work with that 291 | Major Component, or to implement a Standard Interface for which an 292 | implementation is available to the public in source code form. A 293 | "Major Component", in this context, means a major essential component 294 | (kernel, window system, and so on) of the specific operating system 295 | (if any) on which the executable work runs, or a compiler used to 296 | produce the work, or an object code interpreter used to run it. 297 | 298 | The "Corresponding Source" for a work in object code form means all 299 | the source code needed to generate, install, and (for an executable 300 | work) run the object code and to modify the work, including scripts to 301 | control those activities. However, it does not include the work's 302 | System Libraries, or general-purpose tools or generally available free 303 | programs which are used unmodified in performing those activities but 304 | which are not part of the work. For example, Corresponding Source 305 | includes interface definition files associated with source files for 306 | the work, and the source code for shared libraries and dynamically 307 | linked subprograms that the work is specifically designed to require, 308 | such as by intimate data communication or control flow between those 309 | subprograms and other parts of the work. 310 | 311 | The Corresponding Source need not include anything that users can 312 | regenerate automatically from other parts of the Corresponding Source. 313 | 314 | The Corresponding Source for a work in source code form is that same 315 | work. 316 | 317 | ### 2. Basic Permissions. 318 | 319 | All rights granted under this License are granted for the term of 320 | copyright on the Program, and are irrevocable provided the stated 321 | conditions are met. This License explicitly affirms your unlimited 322 | permission to run the unmodified Program. The output from running a 323 | covered work is covered by this License only if the output, given its 324 | content, constitutes a covered work. This License acknowledges your 325 | rights of fair use or other equivalent, as provided by copyright law. 326 | 327 | You may make, run and propagate covered works that you do not convey, 328 | without conditions so long as your license otherwise remains in force. 329 | You may convey covered works to others for the sole purpose of having 330 | them make modifications exclusively for you, or provide you with 331 | facilities for running those works, provided that you comply with the 332 | terms of this License in conveying all material for which you do not 333 | control copyright. Those thus making or running the covered works for 334 | you must do so exclusively on your behalf, under your direction and 335 | control, on terms that prohibit them from making any copies of your 336 | copyrighted material outside their relationship with you. 337 | 338 | Conveying under any other circumstances is permitted solely under the 339 | conditions stated below. Sublicensing is not allowed; section 10 makes 340 | it unnecessary. 341 | 342 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 343 | 344 | No covered work shall be deemed part of an effective technological 345 | measure under any applicable law fulfilling obligations under article 346 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 347 | similar laws prohibiting or restricting circumvention of such 348 | measures. 349 | 350 | When you convey a covered work, you waive any legal power to forbid 351 | circumvention of technological measures to the extent such 352 | circumvention is effected by exercising rights under this License with 353 | respect to the covered work, and you disclaim any intention to limit 354 | operation or modification of the work as a means of enforcing, against 355 | the work's users, your or third parties' legal rights to forbid 356 | circumvention of technological measures. 357 | 358 | ### 4. Conveying Verbatim Copies. 359 | 360 | You may convey verbatim copies of the Program's source code as you 361 | receive it, in any medium, provided that you conspicuously and 362 | appropriately publish on each copy an appropriate copyright notice; 363 | keep intact all notices stating that this License and any 364 | non-permissive terms added in accord with section 7 apply to the code; 365 | keep intact all notices of the absence of any warranty; and give all 366 | recipients a copy of this License along with the Program. 367 | 368 | You may charge any price or no price for each copy that you convey, 369 | and you may offer support or warranty protection for a fee. 370 | 371 | ### 5. Conveying Modified Source Versions. 372 | 373 | You may convey a work based on the Program, or the modifications to 374 | produce it from the Program, in the form of source code under the 375 | terms of section 4, provided that you also meet all of these 376 | conditions: 377 | 378 | - a) The work must carry prominent notices stating that you modified 379 | it, and giving a relevant date. 380 | - b) The work must carry prominent notices stating that it is 381 | released under this License and any conditions added under 382 | section 7. This requirement modifies the requirement in section 4 383 | to "keep intact all notices". 384 | - c) You must license the entire work, as a whole, under this 385 | License to anyone who comes into possession of a copy. This 386 | License will therefore apply, along with any applicable section 7 387 | additional terms, to the whole of the work, and all its parts, 388 | regardless of how they are packaged. This License gives no 389 | permission to license the work in any other way, but it does not 390 | invalidate such permission if you have separately received it. 391 | - d) If the work has interactive user interfaces, each must display 392 | Appropriate Legal Notices; however, if the Program has interactive 393 | interfaces that do not display Appropriate Legal Notices, your 394 | work need not make them do so. 395 | 396 | A compilation of a covered work with other separate and independent 397 | works, which are not by their nature extensions of the covered work, 398 | and which are not combined with it such as to form a larger program, 399 | in or on a volume of a storage or distribution medium, is called an 400 | "aggregate" if the compilation and its resulting copyright are not 401 | used to limit the access or legal rights of the compilation's users 402 | beyond what the individual works permit. Inclusion of a covered work 403 | in an aggregate does not cause this License to apply to the other 404 | parts of the aggregate. 405 | 406 | ### 6. Conveying Non-Source Forms. 407 | 408 | You may convey a covered work in object code form under the terms of 409 | sections 4 and 5, provided that you also convey the machine-readable 410 | Corresponding Source under the terms of this License, in one of these 411 | ways: 412 | 413 | - a) Convey the object code in, or embodied in, a physical product 414 | (including a physical distribution medium), accompanied by the 415 | Corresponding Source fixed on a durable physical medium 416 | customarily used for software interchange. 417 | - b) Convey the object code in, or embodied in, a physical product 418 | (including a physical distribution medium), accompanied by a 419 | written offer, valid for at least three years and valid for as 420 | long as you offer spare parts or customer support for that product 421 | model, to give anyone who possesses the object code either (1) a 422 | copy of the Corresponding Source for all the software in the 423 | product that is covered by this License, on a durable physical 424 | medium customarily used for software interchange, for a price no 425 | more than your reasonable cost of physically performing this 426 | conveying of source, or (2) access to copy the Corresponding 427 | Source from a network server at no charge. 428 | - c) Convey individual copies of the object code with a copy of the 429 | written offer to provide the Corresponding Source. This 430 | alternative is allowed only occasionally and noncommercially, and 431 | only if you received the object code with such an offer, in accord 432 | with subsection 6b. 433 | - d) Convey the object code by offering access from a designated 434 | place (gratis or for a charge), and offer equivalent access to the 435 | Corresponding Source in the same way through the same place at no 436 | further charge. You need not require recipients to copy the 437 | Corresponding Source along with the object code. If the place to 438 | copy the object code is a network server, the Corresponding Source 439 | may be on a different server (operated by you or a third party) 440 | that supports equivalent copying facilities, provided you maintain 441 | clear directions next to the object code saying where to find the 442 | Corresponding Source. Regardless of what server hosts the 443 | Corresponding Source, you remain obligated to ensure that it is 444 | available for as long as needed to satisfy these requirements. 445 | - e) Convey the object code using peer-to-peer transmission, 446 | provided you inform other peers where the object code and 447 | Corresponding Source of the work are being offered to the general 448 | public at no charge under subsection 6d. 449 | 450 | A separable portion of the object code, whose source code is excluded 451 | from the Corresponding Source as a System Library, need not be 452 | included in conveying the object code work. 453 | 454 | A "User Product" is either (1) a "consumer product", which means any 455 | tangible personal property which is normally used for personal, 456 | family, or household purposes, or (2) anything designed or sold for 457 | incorporation into a dwelling. In determining whether a product is a 458 | consumer product, doubtful cases shall be resolved in favor of 459 | coverage. For a particular product received by a particular user, 460 | "normally used" refers to a typical or common use of that class of 461 | product, regardless of the status of the particular user or of the way 462 | in which the particular user actually uses, or expects or is expected 463 | to use, the product. A product is a consumer product regardless of 464 | whether the product has substantial commercial, industrial or 465 | non-consumer uses, unless such uses represent the only significant 466 | mode of use of the product. 467 | 468 | "Installation Information" for a User Product means any methods, 469 | procedures, authorization keys, or other information required to 470 | install and execute modified versions of a covered work in that User 471 | Product from a modified version of its Corresponding Source. The 472 | information must suffice to ensure that the continued functioning of 473 | the modified object code is in no case prevented or interfered with 474 | solely because modification has been made. 475 | 476 | If you convey an object code work under this section in, or with, or 477 | specifically for use in, a User Product, and the conveying occurs as 478 | part of a transaction in which the right of possession and use of the 479 | User Product is transferred to the recipient in perpetuity or for a 480 | fixed term (regardless of how the transaction is characterized), the 481 | Corresponding Source conveyed under this section must be accompanied 482 | by the Installation Information. But this requirement does not apply 483 | if neither you nor any third party retains the ability to install 484 | modified object code on the User Product (for example, the work has 485 | been installed in ROM). 486 | 487 | The requirement to provide Installation Information does not include a 488 | requirement to continue to provide support service, warranty, or 489 | updates for a work that has been modified or installed by the 490 | recipient, or for the User Product in which it has been modified or 491 | installed. Access to a network may be denied when the modification 492 | itself materially and adversely affects the operation of the network 493 | or violates the rules and protocols for communication across the 494 | network. 495 | 496 | Corresponding Source conveyed, and Installation Information provided, 497 | in accord with this section must be in a format that is publicly 498 | documented (and with an implementation available to the public in 499 | source code form), and must require no special password or key for 500 | unpacking, reading or copying. 501 | 502 | ### 7. Additional Terms. 503 | 504 | "Additional permissions" are terms that supplement the terms of this 505 | License by making exceptions from one or more of its conditions. 506 | Additional permissions that are applicable to the entire Program shall 507 | be treated as though they were included in this License, to the extent 508 | that they are valid under applicable law. If additional permissions 509 | apply only to part of the Program, that part may be used separately 510 | under those permissions, but the entire Program remains governed by 511 | this License without regard to the additional permissions. 512 | 513 | When you convey a copy of a covered work, you may at your option 514 | remove any additional permissions from that copy, or from any part of 515 | it. (Additional permissions may be written to require their own 516 | removal in certain cases when you modify the work.) You may place 517 | additional permissions on material, added by you to a covered work, 518 | for which you have or can give appropriate copyright permission. 519 | 520 | Notwithstanding any other provision of this License, for material you 521 | add to a covered work, you may (if authorized by the copyright holders 522 | of that material) supplement the terms of this License with terms: 523 | 524 | - a) Disclaiming warranty or limiting liability differently from the 525 | terms of sections 15 and 16 of this License; or 526 | - b) Requiring preservation of specified reasonable legal notices or 527 | author attributions in that material or in the Appropriate Legal 528 | Notices displayed by works containing it; or 529 | - c) Prohibiting misrepresentation of the origin of that material, 530 | or requiring that modified versions of such material be marked in 531 | reasonable ways as different from the original version; or 532 | - d) Limiting the use for publicity purposes of names of licensors 533 | or authors of the material; or 534 | - e) Declining to grant rights under trademark law for use of some 535 | trade names, trademarks, or service marks; or 536 | - f) Requiring indemnification of licensors and authors of that 537 | material by anyone who conveys the material (or modified versions 538 | of it) with contractual assumptions of liability to the recipient, 539 | for any liability that these contractual assumptions directly 540 | impose on those licensors and authors. 541 | 542 | All other non-permissive additional terms are considered "further 543 | restrictions" within the meaning of section 10. If the Program as you 544 | received it, or any part of it, contains a notice stating that it is 545 | governed by this License along with a term that is a further 546 | restriction, you may remove that term. If a license document contains 547 | a further restriction but permits relicensing or conveying under this 548 | License, you may add to a covered work material governed by the terms 549 | of that license document, provided that the further restriction does 550 | not survive such relicensing or conveying. 551 | 552 | If you add terms to a covered work in accord with this section, you 553 | must place, in the relevant source files, a statement of the 554 | additional terms that apply to those files, or a notice indicating 555 | where to find the applicable terms. 556 | 557 | Additional terms, permissive or non-permissive, may be stated in the 558 | form of a separately written license, or stated as exceptions; the 559 | above requirements apply either way. 560 | 561 | ### 8. Termination. 562 | 563 | You may not propagate or modify a covered work except as expressly 564 | provided under this License. Any attempt otherwise to propagate or 565 | modify it is void, and will automatically terminate your rights under 566 | this License (including any patent licenses granted under the third 567 | paragraph of section 11). 568 | 569 | However, if you cease all violation of this License, then your license 570 | from a particular copyright holder is reinstated (a) provisionally, 571 | unless and until the copyright holder explicitly and finally 572 | terminates your license, and (b) permanently, if the copyright holder 573 | fails to notify you of the violation by some reasonable means prior to 574 | 60 days after the cessation. 575 | 576 | Moreover, your license from a particular copyright holder is 577 | reinstated permanently if the copyright holder notifies you of the 578 | violation by some reasonable means, this is the first time you have 579 | received notice of violation of this License (for any work) from that 580 | copyright holder, and you cure the violation prior to 30 days after 581 | your receipt of the notice. 582 | 583 | Termination of your rights under this section does not terminate the 584 | licenses of parties who have received copies or rights from you under 585 | this License. If your rights have been terminated and not permanently 586 | reinstated, you do not qualify to receive new licenses for the same 587 | material under section 10. 588 | 589 | ### 9. Acceptance Not Required for Having Copies. 590 | 591 | You are not required to accept this License in order to receive or run 592 | a copy of the Program. Ancillary propagation of a covered work 593 | occurring solely as a consequence of using peer-to-peer transmission 594 | to receive a copy likewise does not require acceptance. However, 595 | nothing other than this License grants you permission to propagate or 596 | modify any covered work. These actions infringe copyright if you do 597 | not accept this License. Therefore, by modifying or propagating a 598 | covered work, you indicate your acceptance of this License to do so. 599 | 600 | ### 10. Automatic Licensing of Downstream Recipients. 601 | 602 | Each time you convey a covered work, the recipient automatically 603 | receives a license from the original licensors, to run, modify and 604 | propagate that work, subject to this License. You are not responsible 605 | for enforcing compliance by third parties with this License. 606 | 607 | An "entity transaction" is a transaction transferring control of an 608 | organization, or substantially all assets of one, or subdividing an 609 | organization, or merging organizations. If propagation of a covered 610 | work results from an entity transaction, each party to that 611 | transaction who receives a copy of the work also receives whatever 612 | licenses to the work the party's predecessor in interest had or could 613 | give under the previous paragraph, plus a right to possession of the 614 | Corresponding Source of the work from the predecessor in interest, if 615 | the predecessor has it or can get it with reasonable efforts. 616 | 617 | You may not impose any further restrictions on the exercise of the 618 | rights granted or affirmed under this License. For example, you may 619 | not impose a license fee, royalty, or other charge for exercise of 620 | rights granted under this License, and you may not initiate litigation 621 | (including a cross-claim or counterclaim in a lawsuit) alleging that 622 | any patent claim is infringed by making, using, selling, offering for 623 | sale, or importing the Program or any portion of it. 624 | 625 | ### 11. Patents. 626 | 627 | A "contributor" is a copyright holder who authorizes use under this 628 | License of the Program or a work on which the Program is based. The 629 | work thus licensed is called the contributor's "contributor version". 630 | 631 | A contributor's "essential patent claims" are all patent claims owned 632 | or controlled by the contributor, whether already acquired or 633 | hereafter acquired, that would be infringed by some manner, permitted 634 | by this License, of making, using, or selling its contributor version, 635 | but do not include claims that would be infringed only as a 636 | consequence of further modification of the contributor version. For 637 | purposes of this definition, "control" includes the right to grant 638 | patent sublicenses in a manner consistent with the requirements of 639 | this License. 640 | 641 | Each contributor grants you a non-exclusive, worldwide, royalty-free 642 | patent license under the contributor's essential patent claims, to 643 | make, use, sell, offer for sale, import and otherwise run, modify and 644 | propagate the contents of its contributor version. 645 | 646 | In the following three paragraphs, a "patent license" is any express 647 | agreement or commitment, however denominated, not to enforce a patent 648 | (such as an express permission to practice a patent or covenant not to 649 | sue for patent infringement). To "grant" such a patent license to a 650 | party means to make such an agreement or commitment not to enforce a 651 | patent against the party. 652 | 653 | If you convey a covered work, knowingly relying on a patent license, 654 | and the Corresponding Source of the work is not available for anyone 655 | to copy, free of charge and under the terms of this License, through a 656 | publicly available network server or other readily accessible means, 657 | then you must either (1) cause the Corresponding Source to be so 658 | available, or (2) arrange to deprive yourself of the benefit of the 659 | patent license for this particular work, or (3) arrange, in a manner 660 | consistent with the requirements of this License, to extend the patent 661 | license to downstream recipients. "Knowingly relying" means you have 662 | actual knowledge that, but for the patent license, your conveying the 663 | covered work in a country, or your recipient's use of the covered work 664 | in a country, would infringe one or more identifiable patents in that 665 | country that you have reason to believe are valid. 666 | 667 | If, pursuant to or in connection with a single transaction or 668 | arrangement, you convey, or propagate by procuring conveyance of, a 669 | covered work, and grant a patent license to some of the parties 670 | receiving the covered work authorizing them to use, propagate, modify 671 | or convey a specific copy of the covered work, then the patent license 672 | you grant is automatically extended to all recipients of the covered 673 | work and works based on it. 674 | 675 | A patent license is "discriminatory" if it does not include within the 676 | scope of its coverage, prohibits the exercise of, or is conditioned on 677 | the non-exercise of one or more of the rights that are specifically 678 | granted under this License. You may not convey a covered work if you 679 | are a party to an arrangement with a third party that is in the 680 | business of distributing software, under which you make payment to the 681 | third party based on the extent of your activity of conveying the 682 | work, and under which the third party grants, to any of the parties 683 | who would receive the covered work from you, a discriminatory patent 684 | license (a) in connection with copies of the covered work conveyed by 685 | you (or copies made from those copies), or (b) primarily for and in 686 | connection with specific products or compilations that contain the 687 | covered work, unless you entered into that arrangement, or that patent 688 | license was granted, prior to 28 March 2007. 689 | 690 | Nothing in this License shall be construed as excluding or limiting 691 | any implied license or other defenses to infringement that may 692 | otherwise be available to you under applicable patent law. 693 | 694 | ### 12. No Surrender of Others' Freedom. 695 | 696 | If conditions are imposed on you (whether by court order, agreement or 697 | otherwise) that contradict the conditions of this License, they do not 698 | excuse you from the conditions of this License. If you cannot convey a 699 | covered work so as to satisfy simultaneously your obligations under 700 | this License and any other pertinent obligations, then as a 701 | consequence you may not convey it at all. For example, if you agree to 702 | terms that obligate you to collect a royalty for further conveying 703 | from those to whom you convey the Program, the only way you could 704 | satisfy both those terms and this License would be to refrain entirely 705 | from conveying the Program. 706 | 707 | ### 13. Use with the GNU Affero General Public License. 708 | 709 | Notwithstanding any other provision of this License, you have 710 | permission to link or combine any covered work with a work licensed 711 | under version 3 of the GNU Affero General Public License into a single 712 | combined work, and to convey the resulting work. The terms of this 713 | License will continue to apply to the part which is the covered work, 714 | but the special requirements of the GNU Affero General Public License, 715 | section 13, concerning interaction through a network will apply to the 716 | combination as such. 717 | 718 | ### 14. Revised Versions of this License. 719 | 720 | The Free Software Foundation may publish revised and/or new versions 721 | of the GNU General Public License from time to time. Such new versions 722 | will be similar in spirit to the present version, but may differ in 723 | detail to address new problems or concerns. 724 | 725 | Each version is given a distinguishing version number. If the Program 726 | specifies that a certain numbered version of the GNU General Public 727 | License "or any later version" applies to it, you have the option of 728 | following the terms and conditions either of that numbered version or 729 | of any later version published by the Free Software Foundation. If the 730 | Program does not specify a version number of the GNU General Public 731 | License, you may choose any version ever published by the Free 732 | Software Foundation. 733 | 734 | If the Program specifies that a proxy can decide which future versions 735 | of the GNU General Public License can be used, that proxy's public 736 | statement of acceptance of a version permanently authorizes you to 737 | choose that version for the Program. 738 | 739 | Later license versions may give you additional or different 740 | permissions. However, no additional obligations are imposed on any 741 | author or copyright holder as a result of your choosing to follow a 742 | later version. 743 | 744 | ### 15. Disclaimer of Warranty. 745 | 746 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 747 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 748 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT 749 | WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT 750 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 751 | A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND 752 | PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 753 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 754 | CORRECTION. 755 | 756 | ### 16. Limitation of Liability. 757 | 758 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 759 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR 760 | CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 761 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES 762 | ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT 763 | NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR 764 | LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM 765 | TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER 766 | PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 767 | 768 | ### 17. Interpretation of Sections 15 and 16. 769 | 770 | If the disclaimer of warranty and limitation of liability provided 771 | above cannot be given local legal effect according to their terms, 772 | reviewing courts shall apply local law that most closely approximates 773 | an absolute waiver of all civil liability in connection with the 774 | Program, unless a warranty or assumption of liability accompanies a 775 | copy of the Program in return for a fee. 776 | 777 | END OF TERMS AND CONDITIONS 778 | 779 | ## How to Apply These Terms to Your New Programs 780 | 781 | If you develop a new program, and you want it to be of the greatest 782 | possible use to the public, the best way to achieve this is to make it 783 | free software which everyone can redistribute and change under these 784 | terms. 785 | 786 | To do so, attach the following notices to the program. It is safest to 787 | attach them to the start of each source file to most effectively state 788 | the exclusion of warranty; and each file should have at least the 789 | "copyright" line and a pointer to where the full notice is found. 790 | 791 | 792 | Copyright (C) 793 | 794 | This program is free software: you can redistribute it and/or modify 795 | it under the terms of the GNU General Public License as published by 796 | the Free Software Foundation, either version 3 of the License, or 797 | (at your option) any later version. 798 | 799 | This program is distributed in the hope that it will be useful, 800 | but WITHOUT ANY WARRANTY; without even the implied warranty of 801 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 802 | GNU General Public License for more details. 803 | 804 | You should have received a copy of the GNU General Public License 805 | along with this program. If not, see . 806 | 807 | Also add information on how to contact you by electronic and paper 808 | mail. 809 | 810 | If the program does terminal interaction, make it output a short 811 | notice like this when it starts in an interactive mode: 812 | 813 | Copyright (C) 814 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 815 | This is free software, and you are welcome to redistribute it 816 | under certain conditions; type `show c' for details. 817 | 818 | The hypothetical commands \`show w' and \`show c' should show the 819 | appropriate parts of the General Public License. Of course, your 820 | program's commands might be different; for a GUI interface, you would 821 | use an "about box". 822 | 823 | You should also get your employer (if you work as a programmer) or 824 | school, if any, to sign a "copyright disclaimer" for the program, if 825 | necessary. For more information on this, and how to apply and follow 826 | the GNU GPL, see . 827 | 828 | The GNU General Public License does not permit incorporating your 829 | program into proprietary programs. If your program is a subroutine 830 | library, you may consider it more useful to permit linking proprietary 831 | applications with the library. If this is what you want to do, use the 832 | GNU Lesser General Public License instead of this License. But first, 833 | please read . 834 | --------------------------------------------------------------------------------