├── .dir-locals.el ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── playwright.yml │ ├── release.yml │ ├── submit-chrome.yml │ └── submit-firefox.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc.cjs ├── .vscode └── settings.json ├── Eldev ├── LICENSE ├── README.md ├── assets ├── PublicSans-Black.woff2 ├── PublicSans-BlackItalic.woff2 ├── PublicSans-Bold.woff2 ├── PublicSans-Regular.woff2 ├── icon-1024x1024-cleaned.png ├── icon-1024x1024.png ├── icon-1024x1024bw.png ├── icon-300x300-min.png └── icon-300x300.png ├── docs ├── intro-video-scaled.mp4 └── intro-video.mp4 ├── e2e ├── a11y.spec.ts ├── bgsw.spec.ts ├── common.ts ├── constants.ts ├── emacs.spec.ts ├── emacs │ ├── change │ │ ├── change-headline.el │ │ ├── change-priority.el │ │ ├── change-state.el │ │ └── change-tags.el │ ├── clock │ │ ├── clock-broken.el │ │ ├── clock-cancel.el │ │ ├── clock-effort.el │ │ ├── clock-in.el │ │ └── clock-out.el │ ├── org │ │ ├── agenda.org │ │ ├── change.org │ │ ├── clock-broken.org │ │ └── clock.org │ └── setup │ │ ├── init.el │ │ └── setup-mode.el ├── fixture.ts ├── hooks.spec.ts ├── loading.spec.ts ├── ui.spec.ts └── ws.spec.ts ├── jest.config.mjs ├── lisp ├── org-newtab-agenda.el ├── org-newtab-item.el ├── org-newtab-mode.el ├── org-newtab-server.el ├── org-newtab-store.el └── org-newtab.el ├── locales └── en │ └── messages.json ├── org-newtab.code-workspace ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── src ├── Globals.d.ts ├── app │ ├── actions.ts │ ├── hooks.ts │ ├── middleware.ts │ ├── rootReducer.ts │ ├── storage.ts │ └── store.ts ├── background │ ├── Connections.ts │ ├── MasterWS.ts │ ├── Storage.ts │ ├── index.ts │ └── messaging.ts ├── components │ ├── BehaviorPanel │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── Button │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── Checkbox │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── ConnectionStatusIndicator │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── DebugPanel │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── LayoutPanel │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── LoadingBar │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── Options │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── OptionsToggle │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── OrgItem │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── TabBar │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── ThemingPanel │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ ├── Time │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts │ └── WidgetArea │ │ ├── index.tsx │ │ ├── style.module.css │ │ └── style.module.css.d.ts ├── lib │ ├── Icon.tsx │ ├── Persistor.ts │ ├── Port.ts │ ├── Socket.ts │ ├── constants.ts │ ├── logging.ts │ ├── messages.ts │ ├── types.ts │ └── wdyr.ts ├── modules │ ├── emacs │ │ └── emacsSlice.ts │ ├── layout │ │ └── layoutSlice.ts │ ├── msg │ │ └── msgSlice.ts │ ├── role │ │ └── roleSlice.ts │ ├── ui │ │ └── uiSlice.ts │ └── ws │ │ └── wsSlice.ts └── newtab │ ├── font.css │ ├── index.css │ ├── index.html │ └── index.tsx ├── test └── org-newtab-test.el └── tsconfig.json /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables 2 | ;;; For more information see (info "(emacs) Directory Variables") 3 | 4 | ((emacs-lisp-mode . ((indent-tabs-mode . nil) 5 | (fill-column . 80) 6 | (package-lint-main-file . "lisp/org-newtab.el")))) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style=space 13 | indent_size = 2 14 | 15 | [*.el] 16 | indent_style=space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Zweihander-Main 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build extension and plugin' 2 | on: 3 | workflow_call: 4 | inputs: 5 | platform: 6 | required: false 7 | type: string 8 | description: 'The platform to build the extension for; chrome, firefox, emacs, or all. Default is all.' 9 | default: 'all' 10 | 11 | env: 12 | nodeVersion: 19 13 | pnpmVersion: 8 14 | retentionDays: 14 15 | emacsVersion: 27.1 # min specified in emacs file 16 | 17 | jobs: 18 | build-packages: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - uses: pnpm/action-setup@v3 27 | with: 28 | version: ${{ env.pnpmVersion }} 29 | 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ env.nodeVersion }} 33 | cache: 'pnpm' 34 | 35 | - name: Install dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Build the extension for chrome 39 | if: ${{ inputs.platform == 'all' || inputs.platform == 'chrome' }} 40 | run: pnpm run build 41 | 42 | - name: Package the extension for chrome 43 | if: ${{ inputs.platform == 'all' || inputs.platform == 'chrome' }} 44 | run: pnpm run package 45 | 46 | - name: Add Chrome extension to artifacts 47 | if: ${{ inputs.platform == 'all' || inputs.platform == 'chrome' }} 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: chrome-mv3-prod.zip 51 | path: build/chrome-mv3-prod.zip 52 | retention-days: ${{ env.retentionDays }} 53 | 54 | - name: Build the extension for firefox 55 | if: ${{ inputs.platform == 'all' || inputs.platform == 'firefox' }} 56 | run: pnpm run build --target=firefox-mv3 57 | 58 | - name: Package the extension for firefox 59 | if: ${{ inputs.platform == 'all' || inputs.platform == 'firefox' }} 60 | run: pnpm run package --target=firefox-mv3 61 | 62 | - name: Add Firefox extension to artifacts 63 | if: ${{ inputs.platform == 'all' || inputs.platform == 'firefox' }} 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: firefox-mv3-prod.zip 67 | path: build/firefox-mv3-prod.zip 68 | retention-days: ${{ env.retentionDays }} 69 | 70 | - name: Set up Emacs 71 | if: ${{ inputs.platform == 'all' || inputs.platform == 'emacs' }} 72 | uses: jcs090218/setup-emacs@master 73 | with: 74 | version: ${{ env.emacsVersion }} 75 | 76 | - name: Install Eldev 77 | if: ${{ inputs.platform == 'all' || inputs.platform == 'emacs' }} 78 | uses: emacs-eldev/setup-eldev@v1 79 | 80 | - name: Build the emacs plugin 81 | if: ${{ inputs.platform == 'all' || inputs.platform == 'emacs' }} 82 | run: | 83 | BUILD_OUTPUT_PATH=`eldev package --print-filename | sed -n 'x;$p'` 84 | echo "Build_output_path=$BUILD_OUTPUT_PATH" >> "$GITHUB_ENV" 85 | 86 | - name: Get basename 87 | if: ${{ inputs.platform == 'all' || inputs.platform == 'emacs' }} 88 | run: | 89 | BUILD_BASENAME=`basename ${{ env.Build_output_path }}` 90 | echo "Build_basename=$BUILD_BASENAME" >> "$GITHUB_ENV" 91 | 92 | - name: Add Emacs plugin to artifacts 93 | if: ${{ inputs.platform == 'all' || inputs.platform == 'emacs' }} 94 | uses: actions/upload-artifact@v4 95 | with: 96 | name: ${{ env.Build_basename }} 97 | path: dist/${{ env.Build_basename }} 98 | retention-days: ${{ env.retentionDays }} 99 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: 'Run E2E tests using Playwright' 2 | on: 3 | push: 4 | branches: [master] 5 | tags: 6 | - '**' 7 | paths-ignore: 8 | - 'README.md' 9 | - 'LICENSE' 10 | - 'docs' 11 | - '.vscode/**' 12 | - 'org-newtab.code-workspace' 13 | - '.gitignore' 14 | - '.github/**' 15 | pull_request: 16 | branches: [master] 17 | paths-ignore: 18 | - 'README.md' 19 | - 'LICENSE' 20 | - 'docs' 21 | - '.vscode/**' 22 | - 'org-newtab.code-workspace' 23 | - '.gitignore' 24 | - '.github/**' 25 | workflow_dispatch: 26 | 27 | env: 28 | emacsVersion: 27.1 # min specified in Emacs lisp package 29 | nodeVersion: 19 30 | pnpmVersion: 8 31 | reportRetentionDays: 14 32 | 33 | jobs: 34 | playwright-tests: 35 | timeout-minutes: 60 36 | runs-on: ubuntu-latest 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | shardIndex: [1, 2, 3, 4] 41 | shardTotal: [4] 42 | steps: 43 | - name: Setup Emacs 44 | uses: jcs090218/setup-emacs@master 45 | with: 46 | version: ${{ env.emacsVersion }} 47 | 48 | - name: Checkout 49 | uses: actions/checkout@v4 50 | with: 51 | fetch-depth: 0 52 | 53 | - uses: pnpm/action-setup@v3 54 | with: 55 | version: ${{ env.pnpmVersion }} 56 | 57 | - uses: actions/setup-node@v4 58 | with: 59 | node-version: ${{ env.nodeVersion }} 60 | cache: 'pnpm' 61 | 62 | - name: Install dependencies 63 | run: pnpm install --frozen-lockfile 64 | 65 | - name: Install Playwright browsers 66 | run: npx playwright install --with-deps chromium # only using the one browser for now 67 | 68 | - name: Build extension for testing 69 | run: pnpm run build 70 | 71 | - name: Run Playwright tests 72 | run: xvfb-run pnpm run test:e2e --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} 73 | 74 | - name: Upload blob e2e report to GitHub Actions Artifacts 75 | if: always() 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: blob-report-${{ matrix.shardIndex }} 79 | path: blob-report 80 | retention-days: 1 81 | 82 | merge-reports: 83 | # Merge reports after playwright-tests, even if some shards have failed 84 | if: always() 85 | needs: [playwright-tests] 86 | 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v4 90 | with: 91 | fetch-depth: 0 92 | 93 | - uses: pnpm/action-setup@v3 94 | with: 95 | version: ${{ env.pnpmVersion }} 96 | 97 | - uses: actions/setup-node@v4 98 | with: 99 | node-version: ${{ env.nodeVersion }} 100 | cache: 'pnpm' 101 | 102 | - name: Install playwright 103 | run: pnpm install playwright 104 | 105 | - name: Download blob reports from GitHub Actions Artifacts 106 | uses: actions/download-artifact@v4 107 | with: 108 | path: all-blob-reports 109 | pattern: blob-report-* 110 | merge-multiple: true 111 | 112 | - name: Merge into HTML Report 113 | run: npx playwright merge-reports --reporter html ./all-blob-reports 114 | 115 | - name: Upload HTML report 116 | uses: actions/upload-artifact@v4 117 | with: 118 | name: html-report--attempt-${{ github.run_attempt }} 119 | path: playwright-report 120 | retention-days: ${{ env.reportRetentionDays }} 121 | 122 | - name: Merge into Markdown report 123 | run: npx playwright merge-reports --reporter markdown ./all-blob-reports 124 | 125 | - name: Output summary to console 126 | run: cat report.md >> $GITHUB_STEP_SUMMARY 127 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Create new GH release' 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build-for-release: 7 | uses: ./.github/workflows/build.yml 8 | with: 9 | platform: all 10 | 11 | create-gh-release: 12 | runs-on: ubuntu-latest 13 | if: startsWith(github.ref, 'refs/tags/') 14 | needs: [build-for-release] 15 | steps: 16 | - name: Get ver tag 17 | id: get_tag 18 | run: | 19 | TAG="${GITHUB_REF#refs/tags/}" 20 | echo "Tag=$TAG" >> "$GITHUB_ENV" 21 | TAG_NUM="${TAG#v}" 22 | echo "Tag_num=$TAG_NUM" >> "$GITHUB_ENV" 23 | 24 | - name: Download Chrome artifact 25 | uses: actions/download-artifact@v4 26 | with: 27 | name: chrome-mv3-prod.zip 28 | 29 | - name: Download Firefox artifact 30 | uses: actions/download-artifact@v4 31 | with: 32 | name: firefox-mv3-prod.zip 33 | 34 | - name: Download Emacs artifact 35 | uses: actions/download-artifact@v4 36 | with: 37 | name: org-newtab-${{ env.Tag_num }}.tar 38 | 39 | - name: Release 40 | uses: softprops/action-gh-release@v1 41 | with: 42 | files: | 43 | firefox-mv3-prod.zip 44 | chrome-mv3-prod.zip 45 | org-newtab-${{ env.Tag_num }}.tar 46 | tag_name: ${{ env.Tag }} 47 | release_name: ${{ env.Tag }} 48 | generate_release_notes: true 49 | draft: true 50 | -------------------------------------------------------------------------------- /.github/workflows/submit-chrome.yml: -------------------------------------------------------------------------------- 1 | name: 'Submit to Chrome Store' 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build-chrome: 7 | uses: ./.github/workflows/build.yml 8 | with: 9 | platform: chrome 10 | 11 | submit-chrome: 12 | runs-on: ubuntu-latest 13 | needs: [build-chrome] 14 | steps: 15 | - name: Download Chrome artifact 16 | uses: actions/download-artifact@v4 17 | with: 18 | name: chrome-mv3-prod.zip 19 | 20 | - name: Browser Platform Publish for Chrome 21 | uses: PlasmoHQ/bpp@v3 22 | with: 23 | keys: ${{ secrets.SUBMIT_KEYS_CHROME }} 24 | chrome-file: chrome-mv3-prod.zip 25 | verbose: true 26 | -------------------------------------------------------------------------------- /.github/workflows/submit-firefox.yml: -------------------------------------------------------------------------------- 1 | name: 'Submit to Firefox Add-ons' 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build-firefox: 7 | uses: ./.github/workflows/build.yml 8 | with: 9 | platform: firefox 10 | 11 | submit-firefox: 12 | runs-on: ubuntu-latest 13 | needs: [build-firefox] 14 | steps: 15 | - name: Download Firefox artifact 16 | uses: actions/download-artifact@v4 17 | with: 18 | name: firefox-mv3-prod.zip 19 | 20 | - name: Browser Platform Publish for Firefox 21 | uses: PlasmoHQ/bpp@v3 22 | with: 23 | keys: ${{ secrets.SUBMIT_KEYS_FIREFOX }} 24 | firefox-file: firefox-mv3-prod.zip 25 | verbose: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Elisp ### 2 | # Compiled 3 | *.elc 4 | 5 | # Packaging 6 | .cask 7 | 8 | # Backup files 9 | *~ 10 | 11 | # Undo-tree save-files 12 | *.~undo-tree 13 | 14 | ### Emacs ### 15 | # -*- mode: gitignore; -*- 16 | \#*\# 17 | /.emacs.desktop 18 | /.emacs.desktop.lock 19 | auto-save-list 20 | tramp 21 | .\#* 22 | 23 | # Org-mode 24 | .org-id-locations 25 | *_archive 26 | ltximg/** 27 | 28 | # flymake-mode 29 | *_flymake.* 30 | 31 | # eshell files 32 | /eshell/history 33 | /eshell/lastdir 34 | 35 | # elpa packages 36 | /elpa/ 37 | 38 | # reftex files 39 | *.rel 40 | 41 | # AUCTeX auto folder 42 | /auto/ 43 | 44 | # Flycheck 45 | flycheck_*.el 46 | 47 | # server auth directory 48 | /server/ 49 | 50 | # projectiles files 51 | .projectile 52 | 53 | # network security 54 | /network-security.data 55 | 56 | 57 | ### From flycheck ### 58 | # Bundler configuration and lock file 59 | /.bundle/ 60 | /Gemfile.lock 61 | 62 | # Compiled source # 63 | ################### 64 | *.com 65 | *.class 66 | *.dll 67 | *.exe 68 | *.o 69 | *.so 70 | 71 | # Packages # 72 | ############ 73 | # it's better to unpack these files and commit the raw source 74 | # git has its own built in compression methods 75 | *.7z 76 | *.dmg 77 | *.gz 78 | *.iso 79 | *.jar 80 | *.rar 81 | *.tar 82 | *.zip 83 | 84 | # Logs and databases # 85 | ###################### 86 | *.log 87 | *.sql 88 | *.sqlite 89 | 90 | # OS generated files # 91 | ###################### 92 | .DS_Store 93 | .DS_Store? 94 | ._* 95 | .Spotlight-V100 96 | .Trashes 97 | ehthumbs.db 98 | Thumbs.db 99 | 100 | # Logs 101 | logs 102 | *.log 103 | npm-debug.log* 104 | yarn-debug.log* 105 | yarn-error.log* 106 | 107 | # Runtime data 108 | pids 109 | *.pid 110 | *.seed 111 | *.pid.lock 112 | 113 | # Directory for instrumented libs generated by jscoverage/JSCover 114 | lib-cov 115 | 116 | # Coverage directory used by tools like istanbul 117 | coverage 118 | 119 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 120 | .grunt 121 | 122 | # node-waf configuration 123 | .lock-wscript 124 | 125 | # Compiled binary addons (http://nodejs.org/api/addons.html) 126 | build/Release 127 | 128 | # Dependency directory 129 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 130 | node_modules 131 | jspm_packages/ 132 | 133 | #Don't import .tmp or dist folders 134 | .tmp 135 | dist 136 | build 137 | server.js 138 | server.js.map 139 | 140 | #Typescript build 141 | .tsbuildinfo 142 | *.tsbuildinfo 143 | 144 | #SublimeText 145 | *.tmlanguage.cache 146 | *.tmPreferences.cache 147 | *.stTheme.cache 148 | *.sublime-workspace 149 | Package Control.last-run 150 | Package Control.ca-list 151 | Package Control.ca-bundle 152 | Package Control.system-ca-bundle 153 | Package Control.cache/ 154 | Package Control.ca-certs/ 155 | Package Control.merged-ca-bundle 156 | Package Control.user-ca-bundle 157 | oscrypto-ca-bundle.crt 158 | bh_unicode_properties.cache 159 | GitHub.sublime-settings 160 | 161 | # Environment variables -- dotenv, dotenv.env, envrc. Keep example. 162 | .env* 163 | !.env.example 164 | 165 | # Todo information 166 | CURRENT.md 167 | 168 | # dependencies 169 | /node_modules 170 | /.pnp 171 | .pnp.js 172 | 173 | # testing 174 | /coverage 175 | 176 | # nyc test coverage 177 | .nyc_output 178 | 179 | # production 180 | /build 181 | /build-* 182 | /dist 183 | /dist-* 184 | 185 | # misc 186 | .DS_Store 187 | .env.local 188 | .env.development.local 189 | .env.test.local 190 | .env.production.local 191 | 192 | npm-debug.log* 193 | yarn-debug.log* 194 | yarn-error.log* 195 | 196 | # Local Netlify folder 197 | .netlify/* 198 | !/.netlify/functions 199 | 200 | # Bower dependency directory (https://bower.io/) 201 | bower_components 202 | 203 | # Optional npm cache directory 204 | .npm 205 | 206 | # Optional eslint cache 207 | .eslintcache 208 | 209 | # Optional REPL history 210 | .node_repl_history 211 | 212 | # Output of 'npm pack' 213 | *.tgz 214 | 215 | # Yarn Integrity file 216 | .yarn-integrity 217 | 218 | # Local Netlify folder 219 | .netlify 220 | 221 | # Cypress 222 | cypress/results/* 223 | cypress/reports/* 224 | cypress/screenshots/* 225 | cypress/videos/* 226 | 227 | # Added automatically by ‘eldev init’. 228 | /.eldev 229 | /Eldev-local 230 | 231 | # dependencies 232 | /node_modules 233 | /.pnp 234 | .pnp.js 235 | 236 | # testing 237 | /coverage 238 | 239 | #cache 240 | .turbo 241 | .next 242 | .vercel 243 | 244 | # misc 245 | .DS_Store 246 | *.pem 247 | 248 | # debug 249 | npm-debug.log* 250 | yarn-debug.log* 251 | yarn-error.log* 252 | .pnpm-debug.log* 253 | 254 | 255 | # local env files 256 | .env* 257 | 258 | out/ 259 | build/ 260 | dist/ 261 | 262 | # plasmo - https://www.plasmo.com 263 | .plasmo 264 | 265 | # bpp - http://bpp.browser.market/ 266 | keys.json 267 | google.json 268 | 269 | # typescript 270 | .tsbuildinfo 271 | /test-results/ 272 | /playwright-report/ 273 | /playwright/.cache/ 274 | /test-results/ 275 | /playwright-report/ 276 | /playwright/.cache/ 277 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v19.8.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .typings 3 | .netlify 4 | package-lock.json 5 | public 6 | node_modules 7 | coverage 8 | __generated__ 9 | .vscode 10 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | module.exports = { 5 | plugins: [require.resolve('@plasmohq/prettier-plugin-sort-imports')], 6 | importOrder: ['^@plasmohq/(.*)$', '^~(.*)$', '^[./]'], 7 | importOrderSeparation: true, 8 | importOrderSortSpecifiers: true, 9 | }; 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "less.validate": false, 4 | "scss.validate": false, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "files.exclude": { 8 | "**/.cache": true, 9 | "**/node_modules": true, 10 | "**/.cask": true 11 | }, 12 | "vim.useSystemClipboard": true, 13 | "cSpell.words": [ 14 | "ALLTAGS", 15 | "Backoff", 16 | "BGSW", 17 | "Eldev", 18 | "Elisp", 19 | "Everytime", 20 | "fontsource", 21 | "framereceived", 22 | "framesent", 23 | "hookz", 24 | "indexnewtab", 25 | "liga", 26 | "Newtab", 27 | "newtabs", 28 | "NEWTAG", 29 | "nprogress", 30 | "overriden", 31 | "Parens", 32 | "partysocket", 33 | "persistor", 34 | "Plasmo", 35 | "plasmohq", 36 | "reduxjs", 37 | "resid", 38 | "serviceworker", 39 | "socketerror", 40 | "stylelint", 41 | "TAGSCHANGE", 42 | "tanem", 43 | "testid", 44 | "TOCHANGE", 45 | "UNINSTANTIATED", 46 | "wdyr", 47 | "webextension", 48 | "webextensions", 49 | "welldone", 50 | "Zweihänder", 51 | "zweisolutions" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /Eldev: -------------------------------------------------------------------------------- 1 | ; -*- mode: emacs-lisp; lexical-binding: t; no-byte-compile: t -*- 2 | ;; 1.8 for source directory support 3 | (eldev-require-version "1.8") 4 | 5 | (eldev-use-package-archive 'gnu-elpa) 6 | (eldev-use-package-archive 'melpa) 7 | 8 | ;; Main files 9 | (setf eldev-project-source-dirs '("lisp")) 10 | (setf eldev-project-main-file "org-newtab.el" 11 | eldev-main-fileset "./lisp/org-newtab*.el") 12 | 13 | ;; Test files 14 | (eldev-add-loading-roots 'test "lisp") 15 | (setf eldev-test-fileset "./test/*.el") 16 | 17 | ;; Package-lint 18 | (setq package-lint-main-file "lisp/org-newtab.el" 19 | sentence-end-double-space nil) 20 | -------------------------------------------------------------------------------- /assets/PublicSans-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/PublicSans-Black.woff2 -------------------------------------------------------------------------------- /assets/PublicSans-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/PublicSans-BlackItalic.woff2 -------------------------------------------------------------------------------- /assets/PublicSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/PublicSans-Bold.woff2 -------------------------------------------------------------------------------- /assets/PublicSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/PublicSans-Regular.woff2 -------------------------------------------------------------------------------- /assets/icon-1024x1024-cleaned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/icon-1024x1024-cleaned.png -------------------------------------------------------------------------------- /assets/icon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/icon-1024x1024.png -------------------------------------------------------------------------------- /assets/icon-1024x1024bw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/icon-1024x1024bw.png -------------------------------------------------------------------------------- /assets/icon-300x300-min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/icon-300x300-min.png -------------------------------------------------------------------------------- /assets/icon-300x300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/assets/icon-300x300.png -------------------------------------------------------------------------------- /docs/intro-video-scaled.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/docs/intro-video-scaled.mp4 -------------------------------------------------------------------------------- /docs/intro-video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zweihander-Main/org-newtab/e87f81a76143307cdecbb165c9d280f673619aac/docs/intro-video.mp4 -------------------------------------------------------------------------------- /e2e/a11y.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | closeOptions, 3 | gotoOptPanel, 4 | setupEmacs, 5 | setupClockLisp, 6 | setupOrgFile, 7 | setupWebsocketPort, 8 | storageIsResolved, 9 | teardownEmacs, 10 | } from './common'; 11 | import { 12 | AGENDA_ITEM_TEXT_CLOCKED, 13 | AGENDA_ITEM_TEXT_TODO, 14 | HOW_LONG_TO_WAIT_FOR_RESPONSE, 15 | ITEM_TEXT_LOCATOR, 16 | } from './constants'; 17 | import { test, expect } from './fixture'; 18 | import { checkA11y, injectAxe } from 'axe-playwright'; 19 | 20 | const a11yOptionsFront = { 21 | detailedReport: true, 22 | axeOptions: { 23 | rules: { 24 | 'page-has-heading-one': { enabled: false }, 25 | }, 26 | }, 27 | }; 28 | 29 | test('check accessibility on front page', async ({ page, extensionId }) => { 30 | await page.goto(`chrome-extension://${extensionId}/newtab.html`); 31 | await injectAxe(page); 32 | 33 | await checkA11y(page, undefined, a11yOptionsFront); 34 | }); 35 | 36 | const a11yOptionsPanels = { 37 | detailedReport: true, 38 | axeOptions: { 39 | rules: { 40 | 'aria-allowed-attr': { enabled: false }, 41 | 'aria-required-attr': { enabled: false }, 42 | }, 43 | }, 44 | }; 45 | 46 | test('check accessibility on options pages', async ({ page, extensionId }) => { 47 | await page.goto(`chrome-extension://${extensionId}/newtab.html`); 48 | await injectAxe(page); 49 | await gotoOptPanel(page, 'Behavior'); 50 | await checkA11y(page, undefined, a11yOptionsPanels); 51 | await closeOptions(page); 52 | 53 | await gotoOptPanel(page, 'Layout'); 54 | await checkA11y(page, undefined, a11yOptionsPanels); 55 | await closeOptions(page); 56 | 57 | await gotoOptPanel(page, 'Theming'); 58 | await checkA11y(page, undefined, a11yOptionsPanels); 59 | await closeOptions(page); 60 | 61 | await gotoOptPanel(page, 'Debug'); 62 | await checkA11y(page, undefined, a11yOptionsPanels); 63 | await closeOptions(page); 64 | }); 65 | 66 | test('check accessibility on clocked and non-clocked item', async ({ 67 | context, 68 | extensionId, 69 | }) => { 70 | const { port, emacs, tmpDir } = await setupEmacs(); 71 | await setupOrgFile('agenda.org', tmpDir); 72 | await setupOrgFile('clock.org', tmpDir); 73 | 74 | const tabMaster = await context.newPage(); 75 | await tabMaster.goto(`chrome-extension://${extensionId}/newtab.html`); 76 | await injectAxe(tabMaster); 77 | await storageIsResolved(tabMaster); 78 | await setupWebsocketPort({ port }, tabMaster); 79 | await expect(tabMaster.getByTestId(ITEM_TEXT_LOCATOR)).toContainText( 80 | AGENDA_ITEM_TEXT_TODO, 81 | { timeout: HOW_LONG_TO_WAIT_FOR_RESPONSE } 82 | ); 83 | 84 | await checkA11y(tabMaster, undefined, a11yOptionsFront); 85 | 86 | await setupClockLisp('clock-in.el', tmpDir); 87 | 88 | await expect(tabMaster.getByTestId(ITEM_TEXT_LOCATOR)).toContainText( 89 | AGENDA_ITEM_TEXT_CLOCKED, 90 | { timeout: HOW_LONG_TO_WAIT_FOR_RESPONSE } 91 | ); 92 | 93 | await checkA11y(tabMaster, undefined, a11yOptionsFront); 94 | 95 | teardownEmacs(emacs); 96 | }); 97 | -------------------------------------------------------------------------------- /e2e/bgsw.spec.ts: -------------------------------------------------------------------------------- 1 | import { roleIs } from './common'; 2 | import { test } from './fixture'; 3 | 4 | test('Should load a newtab page', async ({ page, extensionId }) => { 5 | await page.goto(`chrome-extension://${extensionId}/newtab.html`); 6 | await roleIs(page, 'master'); 7 | }); 8 | 9 | test('Should load multiple tabs with different roles', async ({ 10 | extensionId, 11 | context, 12 | }) => { 13 | const tab1 = await context.newPage(); 14 | const tab2 = await context.newPage(); 15 | await tab1.goto(`chrome-extension://${extensionId}/newtab.html`); 16 | await tab2.goto(`chrome-extension://${extensionId}/newtab.html`); 17 | await roleIs(tab1, 'master'); 18 | await roleIs(tab2, 'client'); 19 | }); 20 | 21 | test('Should load multiple tabs and maintain one master role', async ({ 22 | extensionId, 23 | context, 24 | }) => { 25 | test.slow(); 26 | const tab1 = await context.newPage(); 27 | const tab2 = await context.newPage(); 28 | const tab3 = await context.newPage(); 29 | await tab1.goto(`chrome-extension://${extensionId}/newtab.html`); 30 | await tab2.goto(`chrome-extension://${extensionId}/newtab.html`); 31 | await tab3.goto(`chrome-extension://${extensionId}/newtab.html`); 32 | await roleIs(tab1, 'master'); 33 | await roleIs(tab2, 'client'); 34 | await roleIs(tab3, 'client'); 35 | await tab1.close(); 36 | await roleIs(tab2, 'master'); 37 | await roleIs(tab3, 'client'); 38 | const tab4 = await context.newPage(); 39 | await tab4.goto(`chrome-extension://${extensionId}/newtab.html`); 40 | await roleIs(tab4, 'client'); 41 | await tab2.reload(); 42 | await roleIs(tab3, 'master'); 43 | await roleIs(tab2, 'client'); 44 | await roleIs(tab4, 'client'); 45 | await tab2.close(); 46 | await roleIs(tab3, 'master'); 47 | await roleIs(tab4, 'client'); 48 | await tab3.close(); 49 | await roleIs(tab4, 'master'); 50 | await tab4.reload(); 51 | await roleIs(tab4, 'master'); 52 | }); 53 | 54 | test('Should load multiple tabs and switch the master role between them as needed', async ({ 55 | extensionId, 56 | context, 57 | }) => { 58 | const tab1 = await context.newPage(); 59 | const tab2 = await context.newPage(); 60 | const tab3 = await context.newPage(); 61 | await tab1.goto(`chrome-extension://${extensionId}/newtab.html`); 62 | await tab2.goto(`chrome-extension://${extensionId}/newtab.html`); 63 | await tab3.goto(`chrome-extension://${extensionId}/newtab.html`); 64 | await roleIs(tab1, 'master'); 65 | await roleIs(tab2, 'client'); 66 | await roleIs(tab3, 'client'); 67 | await tab1.close(); 68 | await tab2.close(); 69 | await tab3.close(); 70 | const tab4 = await context.newPage(); 71 | await tab4.goto(`chrome-extension://${extensionId}/newtab.html`); 72 | await roleIs(tab4, 'master'); 73 | await tab4.close(); 74 | const tab5 = await context.newPage(); 75 | await tab5.goto(`chrome-extension://${extensionId}/newtab.html`); 76 | await roleIs(tab5, 'master'); 77 | await tab5.close(); 78 | }); 79 | -------------------------------------------------------------------------------- /e2e/constants.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | /** 4 | * Messages.json loading 5 | */ 6 | 7 | type Message = { 8 | message: string; 9 | description: string; 10 | }; 11 | 12 | function loadMessagesJson(locale: string): Record { 13 | const filePath = `locales/${locale}/messages.json`; 14 | const jsonData = fs.readFileSync(filePath, 'utf8'); 15 | return JSON.parse(jsonData) as Record; 16 | } 17 | 18 | const getMessage = (id: string): string => { 19 | const messages = loadMessagesJson(LOCALE); 20 | return (messages[id] && messages[id]?.message) || ''; 21 | }; 22 | 23 | /** 24 | * Constants 25 | */ 26 | 27 | export const HOW_LONG_TO_WAIT_FOR_STORAGE = 20000; 28 | export const HOW_LONG_TO_WAIT_FOR_WEBSOCKET = 15000; 29 | export const HOW_LONG_TO_WAIT_FOR_RESPONSE = 20000; 30 | export const HOW_LONG_TO_TEST_CONNECTION_FOR = 5000; 31 | export const RETRIES_FOR_WEBSOCKET = 0; 32 | export const RETRIES_FOR_EMACS = 0; 33 | export const MAX_RETRIES_FOR_EMACS_CONNECTION = 3; 34 | 35 | export const LOCALE = 'en'; 36 | 37 | export const MASTER_MESSAGE = getMessage('masterRole'); 38 | export const CLIENT_MESSAGE = getMessage('clientRole'); 39 | export const MATCH_QUERY_LABEL = getMessage('matchQuery'); 40 | export const WS_PORT_LABEL = getMessage('wsPort'); 41 | export const INITIAL_STATE_RESOLVED = getMessage('storageResolved'); 42 | export const CONNECTION_STATUS_OPEN = getMessage('connectionStatusOpen'); 43 | 44 | export const GET_ITEM_COMMAND = 'getItem'; 45 | export const WSS_TEST_TEXT = 'WSS test message'; 46 | export const AGENDA_ITEM_TEXT_TODO = 'Sample todo item'; 47 | export const AGENDA_ITEM_TEXT_NEXT = 'Sample next item'; 48 | export const AGENDA_ITEM_TEXT_TAGGED = 'Sample tagged item'; 49 | export const AGENDA_ITEM_TEXT_CLOCKED = 'Sample clocked item'; 50 | export const AGENDA_ITEM_TEXT_EDITED = 'Sample todo edited'; 51 | export const CLOCKED_TIME = '0:01 / 1:23'; 52 | export const CLOCKED_TIME_CHANGED = '0:01 / 2:34'; 53 | export const EFFORTLESS_CLOCKED_TIME = '0:00'; 54 | export const MATCH_QUERY_NEXT = 'TODO="NEXT"'; 55 | export const MATCH_QUERY_TAG = '1#SAMPLETAG'; 56 | export const MATCH_QUERY_NESTED_TAG = '2#OTHERTAG'; 57 | export const MATCH_QUERY_CHANGED_TAG = 'NEWTAG'; 58 | export const MATCH_QUERY_PRIORITY_B = 'PRIORITY="B"'; 59 | export const TAG_COLOR = '#42A5F5'; 60 | export const TAG_COLOR_NESTED_REGEX = 61 | /linear-gradient\(.*, rgb\(0, 255, 51\), rgb\(106, 59, 159\)\)/; 62 | 63 | export const ROLE_LOCATOR = 'websocket-role'; 64 | export const ITEM_TEXT_LOCATOR = 'item-text'; 65 | export const INITIAL_STATE_LOCATOR = 'initial-state'; 66 | export const CONNECTION_STATUS_LOCATOR = 'connection-status'; 67 | export const LOADING_BAR_LOCATOR = 'loading-bar'; 68 | export const OPTIONS_OPEN_BUTTON_LOCATOR = 'options-open-button'; 69 | export const OPTIONS_CLOSE_BUTTON_LOCATOR = 'options-close-button'; 70 | export const BEHAVIOR_BUTTON_LOCATOR = 'behavior-button'; 71 | export const LAYOUT_BUTTON_LOCATOR = 'layout-button'; 72 | export const THEMING_BUTTON_LOCATOR = 'theming-button'; 73 | export const DEBUG_BUTTON_LOCATOR = 'debug-button'; 74 | export const CLOCKED_TIME_LOCATOR = 'clocked-time'; 75 | -------------------------------------------------------------------------------- /e2e/emacs/change/change-headline.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/change.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample todo item") 6 | (beginning-of-line) 7 | (org-edit-headline "Sample todo edited")) 8 | -------------------------------------------------------------------------------- /e2e/emacs/change/change-priority.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/change.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample todo item") 6 | (beginning-of-line) 7 | (org-priority 'down)) 8 | -------------------------------------------------------------------------------- /e2e/emacs/change/change-state.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/change.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample tagged item") 6 | (beginning-of-line) 7 | (org-todo "NEXT")) 8 | -------------------------------------------------------------------------------- /e2e/emacs/change/change-tags.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/change.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample tagged item") 6 | (beginning-of-line) 7 | (org-set-tags "NEWTAG")) 8 | -------------------------------------------------------------------------------- /e2e/emacs/clock/clock-broken.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/clock-broken.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample clocked item") 6 | (beginning-of-line) 7 | (org-clock-in)) 8 | -------------------------------------------------------------------------------- /e2e/emacs/clock/clock-cancel.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/clock.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample clocked item") 6 | (beginning-of-line) 7 | (org-clock-in) 8 | (sleep-for 5) 9 | (org-clock-cancel)) 10 | -------------------------------------------------------------------------------- /e2e/emacs/clock/clock-effort.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/clock.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample clocked item") 6 | (beginning-of-line) 7 | (org-clock-in) 8 | (sleep-for 5) 9 | (org-set-effort nil "2:34")) 10 | -------------------------------------------------------------------------------- /e2e/emacs/clock/clock-in.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/clock.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample clocked item") 6 | (beginning-of-line) 7 | (org-clock-in)) 8 | -------------------------------------------------------------------------------- /e2e/emacs/clock/clock-out.el: -------------------------------------------------------------------------------- 1 | (let ((main-file (expand-file-name "org/clock.org" tmp-dir))) 2 | (find-file main-file) 3 | (org-mode) 4 | (goto-char (point-min)) 5 | (search-forward "Sample clocked item") 6 | (beginning-of-line) 7 | (org-clock-in) 8 | (sleep-for 5) 9 | (org-clock-out)) 10 | -------------------------------------------------------------------------------- /e2e/emacs/org/agenda.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Inbox 2 | * TODO Sample todo item 3 | * NEXT Sample next item 4 | * TODO Sample tagged item :1#SAMPLETAG: 5 | * TODO [#C] Nested multi-tag test :3#PARENTTAG: 6 | ** TODO [#C] The nested item :2#OTHERTAG: 7 | -------------------------------------------------------------------------------- /e2e/emacs/org/change.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Change state 2 | * TODO [#A] Sample todo item :TOCHANGE: 3 | * TODO [#A] Sample tagged item :TOCHANGE: 4 | -------------------------------------------------------------------------------- /e2e/emacs/org/clock-broken.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Broken clock 2 | * TODO [#C] Sample clocked item 3 | -------------------------------------------------------------------------------- /e2e/emacs/org/clock.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Clock out 2 | * TODO [#C] Sample clocked item 3 | :PROPERTIES: 4 | :Effort: 1:23 5 | :END: 6 | :LOGBOOK: 7 | CLOCK: [2024-01-24 Wed 20:07]--[2024-01-24 Wed 20:08] => 0:01 8 | :END: 9 | -------------------------------------------------------------------------------- /e2e/emacs/setup/init.el: -------------------------------------------------------------------------------- 1 | (setq base-dir default-directory) 2 | (setq default-directory (expand-file-name "lisp" base-dir)) 3 | (add-to-list 'load-path default-directory) 4 | (setq tmp-dir (file-name-directory (cadr command-line-args-left))) 5 | 6 | (setq exec-recurse-count 0) 7 | (defun exec-when-file-isnt-locked (file fn) 8 | "Executes the function FN when the file FILE is not locked." 9 | (setq exec-recurse-count (1+ exec-recurse-count)) 10 | (if (> exec-recurse-count 100) 11 | (error "File %s is locked or inaccessible" file) 12 | (if (and (file-exists-p file) (file-readable-p file) (not (file-locked-p file))) 13 | (condition-case nil 14 | (funcall fn) 15 | (error (exec-when-file-isnt-locked file fn))) 16 | ;; Recurse this function until the file is unlocked 17 | (exec-when-file-isnt-locked file fn)))) 18 | 19 | (require 'package) 20 | (package-initialize) 21 | (unless package-archive-contents ; unless packages are not available locally, dont refresh package archives 22 | (package-refresh-contents)) 23 | (let ((main-file (expand-file-name "org-newtab.el" default-directory))) 24 | (exec-when-file-isnt-locked main-file 25 | (lambda () 26 | (package-install-file main-file))) 27 | (exec-when-file-isnt-locked main-file 28 | (lambda () 29 | (load main-file)))) 30 | -------------------------------------------------------------------------------- /e2e/emacs/setup/setup-mode.el: -------------------------------------------------------------------------------- 1 | (defun output-debug-to-console (format-string &rest args) 2 | "Output debug info to the console." 3 | (message (apply #'format format-string args))) 4 | 5 | (defun change-insert-file-contents (main-file old new) 6 | "Insert the contents of MAIN-FILE, replacing OLD with NEW." 7 | (insert (with-temp-buffer 8 | (insert-file-contents main-file) 9 | (goto-char (point-min)) 10 | (while (re-search-forward old nil t) 11 | (replace-match new)) 12 | (goto-char (point-min)) 13 | (buffer-string)))) 14 | 15 | (advice-add 'org-newtab--log :before #'output-debug-to-console) 16 | 17 | (setq org-agenda-files (list (expand-file-name "org" tmp-dir))) 18 | (setq org-todo-keywords '((sequence "TODO" "NEXT" "DONE"))) 19 | (setq org-tag-faces '(("1#SAMPLETAG" . (:foreground "#42A5F5" :weight bold)) 20 | ("2#OTHERTAG" . (:foreground "#6A3B9F" :weight bold)) 21 | ("3#PARENTTAG" . (:foreground "#00FF33" :weight bold)))) 22 | (setq org-clock-persist nil) 23 | (setq make-backup-files nil) 24 | 25 | (org-newtab-mode) 26 | 27 | ;; Mechanism for reading further code from a file 28 | (setq org-newtab--extra-testing-code-file 29 | (expand-file-name "extra-testing-code.el" tmp-dir)) 30 | 31 | (let ((start-time (current-time)) 32 | (file-read nil)) 33 | (while (< (time-to-seconds (time-since start-time)) 60) 34 | (when (and (file-exists-p org-newtab--extra-testing-code-file) 35 | (not file-read)) 36 | (load org-newtab--extra-testing-code-file) 37 | (setq file-read t)) 38 | (sit-for 1))) 39 | -------------------------------------------------------------------------------- /e2e/fixture.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty-pattern */ 2 | import { 3 | test as base, 4 | chromium, 5 | // firefox, 6 | type BrowserContext, 7 | } from '@playwright/test'; 8 | import path from 'path'; 9 | import * as fs from 'fs'; 10 | 11 | export const test = base.extend<{ 12 | headless: boolean; 13 | context: BrowserContext; 14 | extensionId: string; 15 | }>({ 16 | context: async ({ headless, browserName }, use) => { 17 | const pathToExtension = path.join( 18 | __dirname, 19 | `../build/chrome-mv3-${process.env.CI ? 'prod' : 'dev'}` 20 | ); 21 | if (!fs.existsSync(pathToExtension)) { 22 | throw new Error( 23 | `Path to extension does not exist: ${pathToExtension}` 24 | ); 25 | } 26 | let context: BrowserContext; 27 | switch (browserName) { 28 | case 'chromium': 29 | context = await chromium.launchPersistentContext('', { 30 | headless: false, 31 | ignoreHTTPSErrors: true, 32 | args: [ 33 | `--disable-extensions-except=${pathToExtension}`, 34 | `--load-extension=${pathToExtension}`, 35 | headless ? '--headless=new' : '', 36 | ], 37 | }); 38 | await use(context); 39 | await context.close(); 40 | break; 41 | case 'firefox': 42 | // context = await firefox.launchPersistentContext('', { 43 | // headless: false, 44 | // ignoreHTTPSErrors: true, 45 | // acceptDownloads: true, 46 | // viewport: { width: 1920, height: 1080 }, 47 | // args: [`--load-extension=${pathToExtension}`], 48 | // }); 49 | break; 50 | } 51 | }, 52 | extensionId: async ({ context }, use) => { 53 | // for manifest v3: 54 | let [background] = context.serviceWorkers(); 55 | if (!background) 56 | background = await context.waitForEvent('serviceworker'); 57 | 58 | const extensionId = background.url().split('/')[2]; 59 | await use(extensionId); 60 | }, 61 | }); 62 | export const expect = test.expect; 63 | -------------------------------------------------------------------------------- /e2e/loading.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { test, expect } from './fixture'; 3 | import { 4 | setupEmacs, 5 | setupClockLisp, 6 | setupOrgFile, 7 | setupWebsocketPort, 8 | startEmacsProcess, 9 | storageIsResolved, 10 | teardownEmacs, 11 | } from './common'; 12 | import { 13 | AGENDA_ITEM_TEXT_CLOCKED, 14 | AGENDA_ITEM_TEXT_TODO, 15 | HOW_LONG_TO_WAIT_FOR_RESPONSE, 16 | HOW_LONG_TO_WAIT_FOR_STORAGE, 17 | HOW_LONG_TO_WAIT_FOR_WEBSOCKET, 18 | ITEM_TEXT_LOCATOR, 19 | LOADING_BAR_LOCATOR, 20 | RETRIES_FOR_EMACS, 21 | } from './constants'; 22 | 23 | test.describe('Loading bars', () => { 24 | test.describe.configure({ 25 | retries: RETRIES_FOR_EMACS + 2, // not a bug -- can miss loading bar due to fast speed 26 | timeout: 27 | HOW_LONG_TO_WAIT_FOR_STORAGE + 28 | HOW_LONG_TO_WAIT_FOR_WEBSOCKET + 29 | HOW_LONG_TO_WAIT_FOR_RESPONSE, 30 | }); 31 | 32 | let port: number; 33 | let emacs: ReturnType; 34 | let tmpDir: string; 35 | 36 | test.beforeEach(async () => { 37 | ({ port, emacs, tmpDir } = await setupEmacs()); 38 | }); 39 | 40 | test.afterEach(() => { 41 | teardownEmacs(emacs); 42 | }); 43 | 44 | test('should correspond to adding and removing waiting responses', async ({ 45 | extensionId, 46 | context, 47 | }) => { 48 | await setupOrgFile('agenda.org', tmpDir); 49 | 50 | const tabMaster = await context.newPage(); 51 | await tabMaster.goto(`chrome-extension://${extensionId}/newtab.html`); 52 | 53 | const loadingBar = tabMaster.getByTestId(LOADING_BAR_LOCATOR); 54 | const isLoadingBarVisible = tabMaster.waitForSelector( 55 | `div[data-testid="${LOADING_BAR_LOCATOR}"]`, 56 | { state: 'visible' } 57 | ); 58 | 59 | await storageIsResolved(tabMaster); 60 | await setupWebsocketPort({ port }, tabMaster); 61 | 62 | expect(await isLoadingBarVisible).toBeTruthy(); 63 | 64 | await expect(tabMaster.getByTestId(ITEM_TEXT_LOCATOR)).toContainText( 65 | AGENDA_ITEM_TEXT_TODO, 66 | { timeout: HOW_LONG_TO_WAIT_FOR_RESPONSE } 67 | ); 68 | 69 | await expect(loadingBar).not.toBeVisible(); 70 | }); 71 | 72 | test('should be shown when expecting an unprompted response', async ({ 73 | context, 74 | extensionId, 75 | }) => { 76 | await setupOrgFile('clock.org', tmpDir); 77 | 78 | const tabMaster = await context.newPage(); 79 | await tabMaster.goto(`chrome-extension://${extensionId}/newtab.html`); 80 | 81 | await storageIsResolved(tabMaster); 82 | await setupWebsocketPort({ port }, tabMaster); 83 | 84 | await setupClockLisp('clock-out.el', tmpDir); 85 | 86 | await expect(tabMaster.getByTestId(ITEM_TEXT_LOCATOR)).toContainText( 87 | AGENDA_ITEM_TEXT_CLOCKED, 88 | { timeout: HOW_LONG_TO_WAIT_FOR_RESPONSE } 89 | ); 90 | 91 | // .el file pauses for 5 seconds before clock out 92 | 93 | const isLoadingBarVisible = tabMaster.waitForSelector( 94 | `div[data-testid="${LOADING_BAR_LOCATOR}"]`, 95 | { state: 'visible' } 96 | ); 97 | 98 | expect(await isLoadingBarVisible).toBeTruthy(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /e2e/ui.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | closeOptions, 3 | gotoOptPanel, 4 | roleIs, 5 | storageIsResolved, 6 | } from './common'; 7 | import { CONNECTION_STATUS_LOCATOR } from './constants'; 8 | import { test, expect } from './fixture'; 9 | 10 | test('check layout updates sync between tabs', async ({ 11 | context, 12 | extensionId, 13 | }) => { 14 | const tabMaster = await context.newPage(); 15 | const tabClient = await context.newPage(); 16 | await tabMaster.goto(`chrome-extension://${extensionId}/newtab.html`); 17 | await tabClient.goto(`chrome-extension://${extensionId}/newtab.html`); 18 | await storageIsResolved(tabMaster); 19 | await storageIsResolved(tabClient); 20 | 21 | await roleIs(tabMaster, 'master'); 22 | await roleIs(tabClient, 'client'); 23 | 24 | const initialPositionMaster = await tabMaster 25 | .getByTestId(CONNECTION_STATUS_LOCATOR) 26 | .boundingBox(); 27 | const initialPositionClient = await tabClient 28 | .getByTestId(CONNECTION_STATUS_LOCATOR) 29 | .boundingBox(); 30 | 31 | await gotoOptPanel(tabClient, 'Layout'); 32 | 33 | await tabClient 34 | .getByText('Connection') 35 | .dragTo(tabClient.getByText('Org Item')); 36 | 37 | await closeOptions(tabClient); 38 | 39 | const finalPositionClient = await tabClient 40 | .getByTestId(CONNECTION_STATUS_LOCATOR) 41 | .boundingBox(); 42 | 43 | expect(finalPositionClient?.y).not.toEqual(initialPositionClient?.y); 44 | 45 | const finalPositionMaster = await tabMaster 46 | .getByTestId(CONNECTION_STATUS_LOCATOR) 47 | .boundingBox(); 48 | 49 | expect(finalPositionMaster?.y).not.toEqual(initialPositionMaster?.y); 50 | }); 51 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | import { pathsToModuleNameMapper } from 'ts-jest'; 3 | 4 | const require = createRequire(import.meta.url); 5 | const tsconfig = require('./tsconfig.json'); 6 | 7 | const commonProjectConfig = { 8 | testPathIgnorePatterns: ['/e2e/'], 9 | 10 | setupFiles: ['jest-webextension-mock'], 11 | 12 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 13 | moduleNameMapper: pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { 14 | prefix: '/', 15 | }), 16 | testEnvironment: 'jsdom', 17 | transform: { 18 | '^.+\\.ts?$': ['ts-jest', { isolatedModules: true, useESM: true }], 19 | '^.+\\.tsx?$': [ 20 | 'ts-jest', 21 | { useESM: true, tsconfig: { jsx: 'react-jsx' } }, 22 | ], 23 | }, 24 | }; 25 | 26 | /** 27 | * @type {import('@jest/types').Config.InitialOptions} 28 | */ 29 | const config = { 30 | collectCoverage: true, 31 | coverageDirectory: 'coverage', 32 | coverageProvider: 'v8', 33 | 34 | watchPlugins: ['jest-watch-select-projects'], 35 | projects: [ 36 | { 37 | ...commonProjectConfig, 38 | displayName: 'unit', 39 | testEnvironment: 'jsdom', 40 | testPathIgnorePatterns: ['/e2e/'], 41 | }, 42 | { 43 | ...commonProjectConfig, 44 | displayName: 'eslint', 45 | runner: 'jest-runner-eslint', 46 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], 47 | testPathIgnorePatterns: ['.*.d.ts$'], 48 | testMatch: ['/src/**/*'], 49 | }, 50 | { 51 | ...commonProjectConfig, 52 | displayName: 'prettier', 53 | runner: 'jest-runner-prettier', 54 | testMatch: ['/src/**/*', '/package.json'], 55 | }, 56 | { 57 | ...commonProjectConfig, 58 | displayName: 'stylelint', 59 | runner: 'jest-runner-stylelint', 60 | testMatch: ['/src/**/*'], 61 | moduleFileExtensions: ['css', 'scss'], 62 | }, 63 | ], 64 | }; 65 | 66 | export default config; 67 | -------------------------------------------------------------------------------- /lisp/org-newtab-agenda.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-agenda.el --- Library function for agenda interaction -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | 7 | ;; This file is not part of GNU Emacs. 8 | 9 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 10 | 11 | ;;; License: 12 | 13 | ;; This program is free software: you can redistribute it and/or modify 14 | ;; it under the terms of the GNU Affero General Public License as published 15 | ;; by the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU Affero General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU Affero General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | ;; 28 | ;; This file contains the code for interacting with `org-agenda'. It's 29 | ;; deliberately separated from the main file for usage in async processes. 30 | ;; Because of this, file should be kept fairly dependency-free. 31 | ;; 32 | ;; Assumes`org-agenda-files' has been set. 33 | ;; 34 | ;;; Code: 35 | 36 | (require 'cl-lib) 37 | (require 'org) 38 | (require 'org-element) 39 | (require 'org-duration) 40 | (require 'org-clock) 41 | (require 'color) 42 | (require 'json) 43 | 44 | (defun org-newtab--get-one-agenda-item (filter) 45 | "Return first item from agenda using FILTER in JSONable form." 46 | (let* ((entries (org-map-entries #'org-newtab--process-org-marker 47 | filter 'agenda)) 48 | (first-entry (car entries))) 49 | first-entry)) 50 | 51 | (defun org-newtab--get-clocked-in-item () 52 | "Retrieve the currently clocked-in item in JSONable form." 53 | (let* ((marker org-clock-hd-marker) 54 | (buffer (marker-buffer marker))) 55 | (with-current-buffer buffer 56 | (save-excursion 57 | (goto-char (marker-position marker)) 58 | (org-newtab--process-org-marker t))))) 59 | 60 | (defun org-newtab--process-org-marker (&optional clocked) 61 | "Get an org marker and return a JSONable form of its properties. 62 | Add CLOKED minutes if CLOCKED is non-nil." 63 | (let ((props (org-entry-properties)) 64 | (json-null json-false) 65 | (effort (org-entry-get (point) org-effort-property))) 66 | (when clocked 67 | (setq props 68 | (append props 69 | `(("EFFORT_MINUTES" . 70 | ,(if effort (org-duration-to-minutes effort) nil)) 71 | ("PREVIOUSLY_CLOCKED_MINUTES" . 72 | ,(or org-clock-total-time 0)) 73 | ("CURRENT_CLOCK_START_TIMESTAMP" . 74 | ,(* (time-convert org-clock-start-time 'integer) 1000)))))) 75 | props)) 76 | 77 | (defun org-newtab--string-color-to-hex (string) 78 | "Convert STRING to hex value if it's a color." 79 | (cond ((not string) nil) 80 | ((string-prefix-p "#" string) string) 81 | (t (cl-destructuring-bind (red green blue) 82 | (color-name-to-rgb string) 83 | (color-rgb-to-hex red green blue 2))))) 84 | 85 | (defun org-newtab--get-tag-faces () 86 | "Retrieve `org-tag-faces' variable in JSON. 87 | 88 | Note that this function will not work in a terminal/async context as converting 89 | from color names to hex will use the terminal color codes eg goldenrod=yellow1." 90 | (let ((json-null json-false)) 91 | (mapcar 92 | (lambda (tag-cons) 93 | (let ((tag (car tag-cons)) 94 | (foreground-data (cdr tag-cons))) 95 | (cond ((stringp foreground-data) 96 | (cons tag (org-newtab--string-color-to-hex 97 | foreground-data))) 98 | ((facep foreground-data) 99 | (cons tag (org-newtab--string-color-to-hex 100 | (face-foreground foreground-data)))) 101 | ((listp foreground-data) 102 | (cons tag (org-newtab--string-color-to-hex 103 | (plist-get foreground-data :foreground))))))) 104 | org-tag-faces))) 105 | 106 | (defun org-newtab--save-all-agenda-buffers () 107 | "Save all Org agenda buffers without user confirmation. 108 | Necessary to allow for async queries to use fresh data." 109 | (save-some-buffers t (lambda () (org-agenda-file-p)))) 110 | 111 | (provide 'org-newtab-agenda) 112 | 113 | ;; Local Variables: 114 | ;; coding: utf-8 115 | ;; End: 116 | 117 | ;;; org-newtab-agenda.el ends here 118 | -------------------------------------------------------------------------------- /lisp/org-newtab-item.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-item.el --- Toggle WebSocket server and hooks -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | 7 | ;; This file is not part of GNU Emacs. 8 | 9 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 10 | 11 | ;;; License: 12 | 13 | ;; This program is free software: you can redistribute it and/or modify 14 | ;; it under the terms of the GNU Affero General Public License as published 15 | ;; by the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU Affero General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU Affero General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; This file deals with the 'business logic' around fetching an item based 29 | ;; on clocked status and given match query. 30 | 31 | ;;; Code: 32 | 33 | (require 'org-newtab) 34 | (require 'org-newtab-server) 35 | (require 'org-newtab-agenda) 36 | (require 'org-newtab-store) 37 | (require 'async) 38 | 39 | (defun org-newtab--send-tag-faces () 40 | "Send the tag faces to the client." 41 | (let* ((tags (org-newtab--get-tag-faces)) 42 | (data-packet (list :type "TAGS" :data tags))) 43 | (org-newtab--send-data (json-encode data-packet)))) 44 | 45 | (defun org-newtab--send-match (query &optional resid) 46 | "Send the current match for query QUERY to the client -- with RESID if provided." 47 | (org-newtab--dispatch 'find-match `(:resid ,resid)) 48 | (let ((own-task (org-newtab--selected-async-priority-task))) 49 | (unless resid ; Not a response to extension, coming from hook/emacs side 50 | (org-newtab--send-data (json-encode `(:type "FINDING" :data ,own-task)))) 51 | (async-start 52 | `(lambda () 53 | ,(async-inject-variables "\\`load-path\\'") 54 | ,(async-inject-variables "\\`org-agenda-files\\'") 55 | ,(async-inject-variables "\\`org-todo-keywords\\'") 56 | (let ((inhibit-message t)) ; TODO: freezes if prompted for input -- test further 57 | (require 'org-newtab-agenda) 58 | (org-newtab--get-one-agenda-item ',query))) 59 | `(lambda (result) 60 | (let ((data-packet (list :type "ITEM" :data result))) 61 | (when ,resid 62 | (setq data-packet (plist-put data-packet :resid ,resid))) 63 | (if (equal ,own-task (org-newtab--selected-async-priority-task)) 64 | (progn (org-newtab--send-data (json-encode data-packet)) 65 | (org-newtab--dispatch 'send-item)) 66 | (org-newtab--log 67 | "[Item] %s" "Async task priority changed, older request dropped"))))))) 68 | 69 | (defun org-newtab--send-clkd-item (&optional resid) 70 | "Send the current clocked-in item to the client -- with RESID if provided." 71 | (org-newtab--dispatch 'send-item) 72 | (let* ((item (org-newtab--get-clocked-in-item)) 73 | (data-packet (list :type "ITEM" :data item))) 74 | (when resid 75 | (setq data-packet (plist-put data-packet :resid resid))) 76 | (org-newtab--send-data (json-encode data-packet)))) 77 | 78 | (defun org-newtab--get-item (&optional payload) 79 | "Send an item to the extension based on :query and :resid in PAYLOAD. 80 | If QUERY is nil, use `org-newtab--last-match-query'. If RESID is nil, ignore." 81 | (let ((query (or (plist-get payload :query) 82 | (org-newtab--selected-last-match-query))) 83 | (resid (plist-get payload :resid))) 84 | (cond ((org-clocking-p) 85 | (org-newtab--send-clkd-item resid)) 86 | (t 87 | (org-newtab--send-match query resid))))) 88 | 89 | (defun org-newtab--save-and-get-item (&rest _) 90 | "Send new item to client using last recorded match query." 91 | (org-newtab--save-all-agenda-buffers) 92 | (org-newtab--get-item)) 93 | 94 | (provide 'org-newtab-item) 95 | 96 | ;; Local Variables: 97 | ;; coding: utf-8 98 | ;; End: 99 | 100 | ;;; org-newtab-item.el ends here 101 | -------------------------------------------------------------------------------- /lisp/org-newtab-mode.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-mode.el --- Toggle WebSocket server and hooks -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | 7 | ;; This file is not part of GNU Emacs. 8 | 9 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 10 | 11 | ;;; License: 12 | 13 | ;; This program is free software: you can redistribute it and/or modify 14 | ;; it under the terms of the GNU Affero General Public License as published 15 | ;; by the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU Affero General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU Affero General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; This file provides a minor mode to toggle the WebSocket server and org hooks. 29 | ;; Hook related code primarily goes here. 30 | 31 | ;;; Code: 32 | 33 | (eval-when-compile 34 | (cl-pushnew (expand-file-name default-directory) load-path)) 35 | 36 | (require 'org-newtab-store) 37 | (require 'org-newtab-server) 38 | (require 'org-newtab-item) 39 | 40 | (defun org-newtab--on-hook-clock-in (&rest _) 41 | "From `org-clock-in-hook', send new item to client." 42 | (org-newtab--dispatch 'hk-clk-in)) 43 | 44 | (defun org-newtab--on-hook-clock-out (&rest _) 45 | "From `org-clock-out-hook', send new item to client." 46 | (org-newtab--dispatch 'hk-clk-out)) 47 | 48 | (defun org-newtab--on-hook-clock-cancel (&rest _) 49 | "From `org-clock-cancel-hook', send new item to client." 50 | (org-newtab--dispatch 'hk-clk-cancel)) 51 | 52 | (defun org-newtab--on-hook-todo-change (&optional change-data) 53 | "From `org-trigger-hook', send new query if CHANGE-DATA changed (todo change)." 54 | (when change-data 55 | (let ((to (substring-no-properties (plist-get change-data :to))) 56 | (from (substring-no-properties (plist-get change-data :from)))) 57 | (unless (string-match-p from to) 58 | (org-newtab--dispatch 'hk-todo-chg))))) 59 | 60 | (defun org-newtab--on-hook-after-tags-change (&rest _) 61 | "From `org-after-tags-change-hook', send new item to client." 62 | (org-newtab--dispatch 'hk-tags-chg)) 63 | 64 | (defun org-newtab--on-hook-after-refile-insert (&rest _) 65 | "From `org-after-refile-insert-hook', send new item to client." 66 | (org-newtab--dispatch 'hk-refile)) 67 | 68 | (defun org-newtab--on-adv-edit-headline (&rest _) 69 | "From `org-edit-headline', send new item to client." 70 | (org-newtab--dispatch 'adv-edit-hl)) 71 | 72 | (defun org-newtab--on-adv-priority (&rest _) 73 | "From `org-priority', send new item to client." 74 | (org-newtab--dispatch 'adv-pri-chg)) 75 | 76 | (defun org-newtab--on-adv-set-effort (&rest _) 77 | "From `org-set-effort', send new item to client." 78 | (org-newtab--dispatch 'adv-effort-chg)) 79 | 80 | (defconst org-newtab--hook-assocs 81 | '((org-clock-in-hook . org-newtab--on-hook-clock-in) 82 | (org-clock-out-hook . org-newtab--on-hook-clock-out) 83 | (org-clock-cancel-hook . org-newtab--on-hook-clock-cancel) 84 | (org-trigger-hook . org-newtab--on-hook-todo-change) 85 | (org-after-tags-change-hook . org-newtab--on-hook-after-tags-change) 86 | (org-after-refile-insert-hook . org-newtab--on-hook-after-refile-insert)) 87 | "Association list of hooks and functions to append to them.") 88 | 89 | ;; TODO: can determine if the client todo is the headline being edited 90 | ;; - Note that using the match query method, it should never change the item 91 | ;; sent as you can't match on headline 92 | (defconst org-newtab--advice-assocs 93 | '((org-edit-headline . org-newtab--on-adv-edit-headline) 94 | (org-priority . org-newtab--on-adv-priority) 95 | (org-set-effort . org-newtab--on-adv-set-effort)) 96 | "Association list of functions and advice to append to them.") 97 | 98 | (defconst org-newtab--sub-assocs 99 | '((ext-get-item . org-newtab--get-item) 100 | (ext-open . org-newtab--send-tag-faces) 101 | (hk-clk-in . org-newtab--send-clkd-item) 102 | (hk-clk-out . org-newtab--save-and-get-item) 103 | (hk-clk-cancel . org-newtab--save-and-get-item) 104 | (hk-todo-chg . org-newtab--save-and-get-item) 105 | (hk-tags-chg . org-newtab--save-and-get-item) 106 | (hk-refile . org-newtab--save-and-get-item) 107 | (adv-edit-hl . org-newtab--save-and-get-item) 108 | (adv-pri-chg . org-newtab--save-and-get-item) 109 | (adv-effort-chg . org-newtab--save-and-get-item)) 110 | "Association list of action types and subscriber functions to them.") 111 | 112 | ;;;###autoload 113 | (define-minor-mode 114 | org-newtab-mode 115 | "Enable `org-newtab'. 116 | Start the websocket server and add hooks in." 117 | :lighter " org-newtab" 118 | :global t 119 | :group 'org-newtab 120 | :init-value nil 121 | (cond 122 | (org-newtab-mode 123 | (org-newtab--start-server) 124 | (dolist (assoc org-newtab--sub-assocs) 125 | (org-newtab--subscribe (car assoc) (cdr assoc))) 126 | (dolist (assoc org-newtab--hook-assocs) 127 | (add-hook (car assoc) (cdr assoc))) 128 | (dolist (assoc org-newtab--advice-assocs) 129 | (advice-add (car assoc) :after (cdr assoc)))) 130 | (t 131 | (org-newtab--close-server) 132 | (org-newtab--clear-subscribers) 133 | (dolist (assoc org-newtab--hook-assocs) 134 | (remove-hook (car assoc) (cdr assoc))) 135 | (dolist (assoc org-newtab--advice-assocs) 136 | (advice-remove (car assoc) (cdr assoc)))))) 137 | 138 | (provide 'org-newtab-mode) 139 | 140 | ;; Local Variables: 141 | ;; coding: utf-8 142 | ;; End: 143 | 144 | ;;; org-newtab-mode.el ends here 145 | -------------------------------------------------------------------------------- /lisp/org-newtab-server.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-server.el --- WebSocket server to talk to the browser -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | 7 | ;; This file is not part of GNU Emacs. 8 | 9 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 10 | 11 | ;;; License: 12 | 13 | ;; This program is free software: you can redistribute it and/or modify 14 | ;; it under the terms of the GNU Affero General Public License as published 15 | ;; by the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU Affero General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU Affero General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; This file provides the WebSocket server and related callback functions for 29 | ;; dealing with receiving/sending messages. Asynchronous tasks are also handled 30 | ;; here. 31 | 32 | ;;; Code: 33 | 34 | (eval-when-compile 35 | (cl-pushnew (expand-file-name default-directory) load-path)) 36 | 37 | (require 'org-newtab) 38 | (require 'org-newtab-store) 39 | (require 'org-clock) 40 | (require 'websocket) 41 | 42 | (defvar org-newtab--ws-socket nil 43 | "The WebSocket for `org-newtab'.") 44 | 45 | (defvar org-newtab--ws-server nil 46 | "The WebSocket server for `org-newtab'.") 47 | 48 | (defvar org-newtab--debug-mode nil 49 | "Whether or not to turn on every debugging tool.") 50 | 51 | (defun org-newtab--debug-mode () 52 | "Turn on every debug setting." 53 | (setq org-newtab--debug-mode (not org-newtab--debug-mode)) 54 | (setq debug-on-error org-newtab--debug-mode 55 | websocket-callback-debug-on-error org-newtab--debug-mode 56 | async-debug org-newtab--debug-mode)) 57 | 58 | (defun org-newtab--start-server() 59 | "Start WebSocket server." 60 | (setq org-newtab--ws-server 61 | (websocket-server 62 | org-newtab-ws-port 63 | :host 'local 64 | :on-open #'org-newtab--ws-on-open 65 | :on-message #'org-newtab--ws-on-message 66 | :on-close #'org-newtab--ws-on-close 67 | :on-error #'org-newtab--ws-on-error))) 68 | 69 | (defun org-newtab--close-server() 70 | "Close WebSocket server." 71 | (websocket-server-close org-newtab--ws-server)) 72 | 73 | (defun org-newtab--ws-on-open (ws) 74 | "Open the WebSocket WS and send initial data." 75 | (setq org-newtab--ws-socket ws) 76 | (org-newtab--log "[Server] %s" "on-open") 77 | (org-newtab--dispatch 'ext-open)) 78 | 79 | (defun org-newtab--ws-on-message (_ws frame) 80 | "Take WS and FRAME as arguments when message received." 81 | (org-newtab--log "[Server] %s" "on-message") 82 | (let* ((frame-text (websocket-frame-text frame)) 83 | (json-data (org-newtab--decipher-message-from-frame-text frame-text))) 84 | (org-newtab--log "[Server] Received %S from client" json-data) 85 | (let ((command (plist-get json-data :command)) 86 | (resid (plist-get json-data :resid)) 87 | (query (plist-get json-data :data))) 88 | (pcase command 89 | ("getItem" (org-newtab--dispatch 'ext-get-item 90 | `(:resid ,resid :query ,query ))) 91 | (_ (org-newtab--log "[Server] %s" "Unknown command from client")))))) 92 | 93 | (defun org-newtab--ws-on-close (_ws) 94 | "Perform when WS is closed." 95 | (setq org-newtab--ws-socket nil) 96 | (org-newtab--dispatch 'ext-close) 97 | (org-newtab--log "[Server] %s" "on-close")) 98 | 99 | (defun org-newtab--ws-on-error (_ws type error) 100 | "Handle ERROR of TYPE from WS." 101 | (org-newtab--log "[Server] Error: %S : %S" (prin1 type) (prin1 error))) 102 | 103 | (defun org-newtab--send-data (data) 104 | "Send DATA to socket. If socket is nil, drop the data and do nothing." 105 | (when org-newtab--ws-socket 106 | (org-newtab--log "[Server] Sending %S to client" data) 107 | (condition-case err 108 | (websocket-send-text org-newtab--ws-socket data) 109 | (error (org-newtab--log "[Server] Error sending data to client: %S" err))))) 110 | 111 | (defun org-newtab--decipher-message-from-frame-text (frame-text) 112 | "Decipher FRAME-TEXT and return the message." 113 | (let* ((json-object-type 'plist) 114 | (json-array-type 'list) 115 | (json (json-read-from-string frame-text))) ;; TODO: error handling for "\261R\30\7", missing data, command, etc. 116 | json)) 117 | 118 | 119 | (provide 'org-newtab-server) 120 | 121 | ;; Local Variables: 122 | ;; coding: utf-8 123 | ;; End: 124 | 125 | ;;; org-newtab-server.el ends here 126 | -------------------------------------------------------------------------------- /lisp/org-newtab-store.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-store.el --- Keep track of app state -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | 7 | ;; This file is not part of GNU Emacs. 8 | 9 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 10 | 11 | ;;; License: 12 | 13 | ;; This program is free software: you can redistribute it and/or modify 14 | ;; it under the terms of the GNU Affero General Public License as published 15 | ;; by the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | ;; 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU Affero General Public License for more details. 22 | ;; 23 | ;; You should have received a copy of the GNU Affero General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; This file deals with the application state. It can be thought of as a simpler 29 | ;; Lisp version of the Flux (Redux) architecture used on the client side 30 | ;; extension. 31 | 32 | ;;; Code: 33 | 34 | (require 'org-newtab) 35 | 36 | (defvar org-newtab--state 37 | '(:last-match-query nil ;str -- The last match query received from the ext. 38 | :async-priority-task nil) ;int -- The async task which currently has priority. 39 | "The application state.") 40 | 41 | (defvar org-newtab--action-subscribers 42 | '(()) 43 | "List of actions with corresponding list of subscribers.") 44 | 45 | (defun org-newtab--reducer (state type &optional payload) 46 | "Take STATE, apply action TYPE with opt PAYLOAD to state, return state. 47 | Avoid side effects and mutations." 48 | (pcase type 49 | ;; Always capture the match query in case it's needed later 50 | ;; (for example, being able to send back data after clock out without 51 | ;; having to ask the extension for the query again) 52 | ('ext-get-item (plist-put state :last-match-query (plist-get payload :query))) 53 | ;; Set a new priority task that will take precedence over any previous 54 | ;; async queries. Use resid if provided, otherwise a random num. 55 | ('find-match (plist-put state :async-priority-task 56 | (or (plist-get payload :resid) (abs (random t))))) 57 | ;; Async task completed and task has priority so it can be cleared 58 | ;; Alternatively, clocked in item has been sent and should clear all other 59 | ;; async tasks 60 | ('send-item (plist-put state :async-priority-task nil)) 61 | ;; Don't save priority tasks between websocket connections 62 | ('ext-close (plist-put state :async-priority-task nil)) 63 | (_ state))) 64 | 65 | (defun org-newtab--dispatch (type &optional payload) 66 | "Run state reducer and subscriptions on action TYPE with optional PAYLOAD." 67 | (org-newtab--log "[Store] Action dispatched: %s >> %s" type payload) 68 | (setq org-newtab--state 69 | (org-newtab--reducer org-newtab--state type payload)) 70 | (let ((subs (alist-get type org-newtab--action-subscribers))) 71 | (when subs 72 | (dolist (func subs) 73 | (if payload (funcall func payload) (funcall func)))))) 74 | 75 | (defun org-newtab--clear-subscribers () 76 | "Clear all subscribers." 77 | (setq org-newtab--action-subscribers '(()))) 78 | 79 | (defun org-newtab--subscribe (type func) 80 | "Subscribe FUNC to the action TYPE." 81 | (let ((action-subs (assoc type org-newtab--action-subscribers))) 82 | (if action-subs 83 | (unless (member func (cdr action-subs)) 84 | (setcdr action-subs (cons func (cdr action-subs)))) 85 | (setq org-newtab--action-subscribers 86 | (cons (cons type (list func)) org-newtab--action-subscribers))))) 87 | 88 | (defun org-newtab--unsubscribe (type func) 89 | "Unsubscribe FUNC from the action TYPE." 90 | (let ((action-subs (assoc type org-newtab--action-subscribers))) 91 | (when action-subs 92 | (setcdr action-subs (remove func (cdr action-subs))) 93 | (when (null (cdr action-subs)) 94 | (setq org-newtab--action-subscribers 95 | (delq action-subs org-newtab--action-subscribers)))))) 96 | 97 | (defun org-newtab--selected-last-match-query () 98 | "Return the last match query." 99 | (plist-get org-newtab--state :last-match-query)) 100 | 101 | (defun org-newtab--selected-async-priority-task () 102 | "Return async task which currently has priority." 103 | (plist-get org-newtab--state :async-priority-task)) 104 | 105 | (provide 'org-newtab-store) 106 | 107 | ;; Local Variables: 108 | ;; coding: utf-8 109 | ;; End: 110 | 111 | ;;; org-newtab-store.el ends here 112 | -------------------------------------------------------------------------------- /lisp/org-newtab.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab.el --- Supercharge your browser's new tab page -*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | ;; Keywords: outlines 7 | ;; Homepage: https://github.com/Zweihander-Main/org-newtab 8 | ;; Version: 0.1.1 9 | ;; Package-Requires: ((emacs "27.1") (websocket "1.14") (async "1.9.7")) 10 | 11 | ;; This file is not part of GNU Emacs. 12 | 13 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 14 | 15 | ;;; License: 16 | 17 | ;; This program is free software: you can redistribute it and/or modify 18 | ;; it under the terms of the GNU Affero General Public License as published 19 | ;; by the Free Software Foundation, either version 3 of the License, or 20 | ;; (at your option) any later version. 21 | ;; 22 | ;; This program is distributed in the hope that it will be useful, 23 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 24 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 25 | ;; GNU Affero General Public License for more details. 26 | ;; 27 | ;; You should have received a copy of the GNU Affero General Public License 28 | ;; along with this program. If not, see . 29 | 30 | ;;; Commentary: 31 | 32 | ;; Org-NewTab is a browser extension which sets the org-agenda task you should 33 | ;; be working on as your new tab page. It's comprised of two parts: a browser 34 | ;; extension and an Emacs package. The Emacs package runs a WebSocket server 35 | ;; which the browser extension talks to. 36 | 37 | ;; The Emacs side is invoked with `org-newtab-mode'. This starts a WebSocket 38 | ;; server which listens on `org-newtab-ws-port'. The browser extension connects 39 | ;; in and will send an org-agenda match query which is then used by Emacs to 40 | ;; find the top task to work on. On connection, the Emacs side will send in 41 | ;; data for `org-tag-faces' used to color the task background in the browser 42 | ;; extension. The Emacs side will send data over when it's either requested 43 | ;; by the browser extension (when it first connects) or when a task is changed 44 | ;; (clocked in/out, marked as done, etc). 45 | 46 | ;;; Code: 47 | 48 | (require 'cl-lib) 49 | 50 | (defgroup org-newtab nil 51 | "A browser new tab page linked to `org-agenda'." 52 | :group 'org-newtab 53 | :prefix "org-newtab-" 54 | :link `(url-link :tag "Github" "https://github.com/Zweihander-Main/org-newtab")) 55 | 56 | (defcustom org-newtab-ws-port 57 | 35942 58 | "Port to server WebSocket server on." 59 | :type 'integer 60 | :group 'org-newtab) 61 | 62 | (defun org-newtab--log (format-string &rest args) 63 | "Log FORMAT-STRING and ARGS to `org-newtab-log-buffer'." 64 | (with-current-buffer (get-buffer-create "*org-newtab-log*") 65 | (goto-char (point-max)) 66 | (insert (apply #'format format-string args)) 67 | (insert "\n"))) 68 | 69 | (provide 'org-newtab) 70 | 71 | (cl-eval-when (load eval) 72 | (cl-pushnew (expand-file-name default-directory) load-path) 73 | (require 'org-newtab-mode)) 74 | 75 | ;; Local Variables: 76 | ;; coding: utf-8 77 | ;; End: 78 | 79 | ;;; org-newtab.el ends here 80 | -------------------------------------------------------------------------------- /locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Org-NewTab", 4 | "description": "Name of the extension" 5 | }, 6 | "extensionDescription": { 7 | "message": "Supercharge your browser's New Tab with Org-Agenda", 8 | "description": "Description of the extension" 9 | }, 10 | "title": { 11 | "message": "New Tab", 12 | "description": "Title of new tab page" 13 | }, 14 | "masterRole": { 15 | "message": "Master", 16 | "description": "Name of master websocket role" 17 | }, 18 | "clientRole": { 19 | "message": "Client", 20 | "description": "Name of client websocket role" 21 | }, 22 | "optionsMenu": { 23 | "message": "Options Menu", 24 | "description": "Label for Options Menu" 25 | }, 26 | "closeOptionsMenu": { 27 | "message": "Close Options Menu", 28 | "description": "Label for closing Options Menu" 29 | }, 30 | "behavior": { 31 | "message": "Behavior", 32 | "description": "Name of options panel for app behavior options" 33 | }, 34 | "layout": { 35 | "message": "Layout", 36 | "description": "Name of options panel for layout options" 37 | }, 38 | "theming": { 39 | "message": "Theming", 40 | "description": "Name of options panel for theming options" 41 | }, 42 | "debug": { 43 | "message": "Debug", 44 | "description": "Name of options panel with debug information" 45 | }, 46 | "matchQuery": { 47 | "message": "Match Query", 48 | "description": "Name of match query input sent to Emacs" 49 | }, 50 | "matchQueryTooltip": { 51 | "message": "Default task search string sent to Emacs. This will be used to find a task when no other task is clocked in.

See Org Manual: Matching tags and properties for more information.

Example:
TODO=\"NEXT\"+PRIORITY=\"A\"-@down-@play-@end", 52 | "description": "Tooltip for match query input" 53 | }, 54 | "wsPort": { 55 | "message": "WebSocket Port", 56 | "description": "Name of websocket port input to connect to Emacs" 57 | }, 58 | "wsPortTooltip": { 59 | "message": "WebSocket port to communicate with Emacs. This must match the port specified in your Emacs configuration:

(setq org-newtab-ws-port 35942)", 60 | "description": "Tooltip for websocket port input" 61 | }, 62 | "saveOptions": { 63 | "message": "Update", 64 | "description": "Label for save options form button" 65 | }, 66 | "saveBehaviorOptionsLabel": { 67 | "message": "Update behavior options", 68 | "description": "Label to save the behavior options" 69 | }, 70 | "storageStatus": { 71 | "message": "Storage", 72 | "description": "Label for storage status" 73 | }, 74 | "storageResolved": { 75 | "message": "Resolved", 76 | "description": "Label for resolved storage status" 77 | }, 78 | "storageUnresolved": { 79 | "message": "Unresolved", 80 | "description": "Label for unresolved storage status" 81 | }, 82 | "websocketRole": { 83 | "message": "WebSocket Role", 84 | "description": "Label for websocket role status" 85 | }, 86 | "tagsData": { 87 | "message": "Tags Data", 88 | "description": "Label for tags data debug info" 89 | }, 90 | "connectionStatusConnecting": { 91 | "message": "Connecting", 92 | "description": "Label for connecting websocket connection status" 93 | }, 94 | "connectionStatusOpen": { 95 | "message": "Connected", 96 | "description": "Label for open websocket connection status" 97 | }, 98 | "connectionStatusClosed": { 99 | "message": "Disconnected", 100 | "description": "Label for closed websocket connection status" 101 | }, 102 | "connectionStatusClosing": { 103 | "message": "Closing", 104 | "description": "Label for closing websocket connection status" 105 | }, 106 | "connectionStatusUninstantiated": { 107 | "message": "Initializing", 108 | "description": "Label for initializing websocket connection status" 109 | }, 110 | "layoutWidgetConnectionStatus": { 111 | "message": "Connection", 112 | "description": "Label for connection status widget" 113 | }, 114 | "layoutWidgetOrgItem": { 115 | "message": "Org Item", 116 | "description": "Label for org item widget" 117 | }, 118 | "layoutWidgetDraggableArea": { 119 | "message": "Draggable widget", 120 | "description": "Label for draggable area widget" 121 | }, 122 | "layoutWidgetDroppableArea": { 123 | "message": "Dropzone for area", 124 | "description": "Label for drop zone for widgets" 125 | }, 126 | "reset": { 127 | "message": "Reset", 128 | "description": "Label for reset buttons (in layout for example)" 129 | }, 130 | "layoutResetLabel": { 131 | "message": "Reset layout to default", 132 | "description": "Label for reset layout button aria-label" 133 | }, 134 | "layoutActive": { 135 | "message": "Active", 136 | "description": "Label for active drop zones" 137 | }, 138 | "layoutInactive": { 139 | "message": "Inactive", 140 | "description": "Label for inactive drop zone" 141 | }, 142 | "themingUntaggedItemBG": { 143 | "message": "Untagged Item Background", 144 | "description": "Label for untagged item background color input" 145 | }, 146 | "themingResetUntaggedItemBG": { 147 | "message": "Reset untagged item background color", 148 | "description": "Label for reset untagged item background color button aria-label" 149 | }, 150 | "pitch": { 151 | "message": "Hey, real quick!
I'm a frontend developer and also help B2B startups looking to grow their sales teams. If you need something built, scaled, or debugged, drop me a line:
hi@zweisolutions.com", 152 | "description": "Developer pitch" 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /org-newtab.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "typescript.enablePromptUseWorkspaceTsdk": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | const headless = !process.argv.includes('--headed'); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: './e2e', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: process.env.CI ? 'blob' : [['html', { open: 'never' }]], 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | // baseURL: 'http://127.0.0.1:3000', 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: 'on-first-retry', 33 | }, 34 | 35 | /* Configure projects for major browsers */ 36 | projects: [ 37 | { 38 | name: 'chromium', 39 | use: { headless, browserName: 'chromium' }, 40 | }, 41 | // Waiting on https://github.com/microsoft/playwright/issues/7297 42 | // { 43 | // name: 'firefox', 44 | // use: { ...devices['Desktop Firefox'] }, 45 | // }, 46 | ], 47 | 48 | /* Run your local dev server before starting the tests */ 49 | // webServer: { 50 | // command: 'npm run start', 51 | // url: 'http://127.0.0.1:3000', 52 | // reuseExistingServer: !process.env.CI, 53 | // }, 54 | }); 55 | -------------------------------------------------------------------------------- /src/Globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit'; 2 | 3 | /** Resets all data in all slices to initial state. */ 4 | export const resetData = createAction('root/reset'); 5 | 6 | /** Flush (write to storage) data. Used in middleware with persistor. */ 7 | export const flushData = createAction('root/flush'); 8 | -------------------------------------------------------------------------------- /src/app/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | import type { TypedUseSelectorHook } from 'react-redux'; 3 | import type { RootState, AppDispatch } from './store'; 4 | 5 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 6 | export const useAppDispatch: () => AppDispatch = useDispatch; 7 | export const useAppSelector: TypedUseSelectorHook = useSelector; 8 | -------------------------------------------------------------------------------- /src/app/middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Middleware, 3 | UnknownAction, 4 | createListenerMiddleware, 5 | isAction, 6 | } from '@reduxjs/toolkit'; 7 | import { ENABLE_REDUX_LOGGING } from 'lib/constants'; 8 | import type { RootState, AppDispatch } from './store'; 9 | import { flushData, resetData } from './actions'; 10 | import Persistor from 'lib/Persistor'; 11 | import { REHYDRATE } from '@plasmohq/redux-persist'; 12 | 13 | const middlewares: Array = []; 14 | 15 | /** 16 | * Used extensively to manage websocket and background messaging 17 | */ 18 | export const listenerMiddleware = createListenerMiddleware(); 19 | 20 | /** 21 | * Clear local storage, reload the window, log errors 22 | */ 23 | listenerMiddleware.startListening({ 24 | actionCreator: resetData, 25 | effect: () => { 26 | chrome.storage.local 27 | .clear() 28 | .then(() => { 29 | // Get new tag information 30 | window.location.reload(); 31 | }) 32 | .catch((err) => { 33 | console.error(err); 34 | }); 35 | }, 36 | }); 37 | 38 | middlewares.push(listenerMiddleware.middleware); 39 | 40 | /** 41 | * Log all actions and the next state when ENABLE_REDUX_LOGGING is true 42 | */ 43 | const loggerMiddleware: Middleware = 44 | (store) => (next) => (action) => { 45 | if (isAction(action)) { 46 | // eslint-disable-next-line no-console 47 | console.log( 48 | 'Action:', 49 | action.type, 50 | (action as UnknownAction)?.payload 51 | ); 52 | } 53 | const result = next(action); 54 | // eslint-disable-next-line no-console 55 | console.log('Next state:', store.getState()); 56 | return result; 57 | }; 58 | 59 | if (ENABLE_REDUX_LOGGING) { 60 | middlewares.push(loggerMiddleware); 61 | } 62 | 63 | /** 64 | * Flush (write to storage) data. 65 | */ 66 | const persistorMiddleware: Middleware = 67 | (state) => (next) => (action) => { 68 | if (flushData.match(action)) { 69 | void Persistor.flush(); 70 | } else if ( 71 | isAction(action) && 72 | action.type === REHYDRATE && 73 | Persistor.isFlushing 74 | ) { 75 | // Ignore rehydration when flushing to ensure no data loss from Emacs 76 | // Rehydration will automatically happen when flushing is complete 77 | return state; 78 | } 79 | return next(action); 80 | }; 81 | 82 | middlewares.push(persistorMiddleware); 83 | 84 | export default middlewares; 85 | -------------------------------------------------------------------------------- /src/app/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction, combineReducers } from '@reduxjs/toolkit'; 2 | import { localStorage } from 'redux-persist-webextension-storage'; 3 | import { createMigrate, persistReducer } from '@plasmohq/redux-persist'; 4 | import type { Storage as StorageType } from '@plasmohq/redux-persist/lib/types'; 5 | import wsReducer, { 6 | WSState, 7 | name as wsSliceName, 8 | persistenceBlacklist as wsSlicePersistenceBlacklist, 9 | } from '../modules/ws/wsSlice'; 10 | import emacsReducer, { 11 | EmacsState, 12 | name as emacsSliceName, 13 | persistenceBlacklist as emacsSlicePersistenceBlacklist, 14 | persistenceVersion as emacsPersistenceVersion, 15 | persistenceMigrations as emacsPersistenceMigrations, 16 | } from '../modules/emacs/emacsSlice'; 17 | import uiReducer, { 18 | UIState, 19 | name as uiSliceName, 20 | persistenceBlacklist as uiSlicePersistenceBlacklist, 21 | } from '../modules/ui/uiSlice'; 22 | import layoutReducer, { 23 | LayoutState, 24 | name as layoutSliceName, 25 | persistenceBlacklist as layoutSlicePersistenceBlacklist, 26 | } from '../modules/layout/layoutSlice'; 27 | import roleReducer, { name as roleSliceName } from '../modules/role/roleSlice'; 28 | import msgReducer, { name as msgSliceName } from '../modules/msg/msgSlice'; 29 | 30 | export const wsPersistConfig = { 31 | key: wsSliceName, 32 | version: 1, 33 | storage: localStorage as StorageType, 34 | blacklist: wsSlicePersistenceBlacklist, 35 | }; 36 | 37 | const persistedWSReducer = persistReducer( 38 | wsPersistConfig, 39 | wsReducer 40 | ); 41 | 42 | export const emacsPersistConfig = { 43 | key: emacsSliceName, 44 | version: emacsPersistenceVersion, 45 | storage: localStorage as StorageType, 46 | blacklist: emacsSlicePersistenceBlacklist, 47 | migrate: createMigrate(emacsPersistenceMigrations), 48 | }; 49 | 50 | const persistedEmacsReducer = persistReducer( 51 | emacsPersistConfig, 52 | emacsReducer 53 | ); 54 | 55 | export const layoutPersistConfig = { 56 | key: layoutSliceName, 57 | version: 1, 58 | storage: localStorage as StorageType, 59 | blacklist: layoutSlicePersistenceBlacklist, 60 | }; 61 | 62 | const persistedLayoutReducer = persistReducer( 63 | layoutPersistConfig, 64 | layoutReducer 65 | ); 66 | 67 | export const uiPersistConfig = { 68 | key: uiSliceName, 69 | version: 1, 70 | storage: localStorage as StorageType, 71 | blacklist: uiSlicePersistenceBlacklist, 72 | }; 73 | 74 | const persistedUiReducer = persistReducer( 75 | uiPersistConfig, 76 | uiReducer 77 | ); 78 | 79 | const rootReducer = combineReducers({ 80 | [msgSliceName]: msgReducer, 81 | [roleSliceName]: roleReducer, 82 | [wsSliceName]: persistedWSReducer, 83 | [emacsSliceName]: persistedEmacsReducer, 84 | [layoutSliceName]: persistedLayoutReducer, 85 | [uiSliceName]: persistedUiReducer, 86 | }); 87 | 88 | export const rootPersistConfig = { 89 | key: 'root', 90 | version: 1, 91 | storage: localStorage as StorageType, 92 | blacklist: [ 93 | msgSliceName, 94 | roleSliceName, 95 | wsSliceName, 96 | emacsSliceName, 97 | uiSliceName, 98 | layoutSliceName, 99 | ], 100 | }; 101 | 102 | const persistedRootReducer = persistReducer(rootPersistConfig, rootReducer); 103 | 104 | export const persistKeys = [ 105 | rootPersistConfig.key, 106 | wsPersistConfig.key, 107 | emacsPersistConfig.key, 108 | uiPersistConfig.key, 109 | layoutPersistConfig.key, 110 | ]; 111 | 112 | export const mockRootReducer = combineReducers({ 113 | [msgSliceName]: msgReducer, 114 | [roleSliceName]: roleReducer, 115 | [wsSliceName]: wsReducer, 116 | [emacsSliceName]: emacsReducer, 117 | [uiSliceName]: uiReducer, 118 | [layoutSliceName]: layoutReducer, 119 | }); 120 | 121 | export default persistedRootReducer; 122 | -------------------------------------------------------------------------------- /src/app/storage.ts: -------------------------------------------------------------------------------- 1 | import { Storage, StorageCallbackMap } from '@plasmohq/storage'; 2 | import { persistKeys } from './rootReducer'; 3 | import { ENABLE_STORAGE_LOGGING } from 'lib/constants'; 4 | import Persistor from 'lib/Persistor'; 5 | 6 | // This is what makes Redux sync properly with multiple pages 7 | const watchKeys = persistKeys.map((key) => `persist:${key}`); 8 | 9 | /** 10 | * Assumption: Items nested past the first level are strings. Therefore shallow 11 | * comparison is sufficient to determine if the value has changed. 12 | */ 13 | interface ReduxChangeObject extends chrome.storage.StorageChange { 14 | oldValue?: Record; 15 | newValue?: Record; 16 | } 17 | 18 | /** 19 | * Manually confirming values have changed as Firefox and Chrome differ in 20 | * triggering onChanged events. Firefox triggers it for every setItem call, 21 | * whereas Chrome/Safari only trigger it when values have changed. 22 | */ 23 | const watchObj = watchKeys.reduce((acc, key) => { 24 | acc[key] = (change: ReduxChangeObject) => { 25 | const { oldValue, newValue } = change; 26 | const updatedKeys = []; 27 | for (const key in oldValue) { 28 | if (oldValue[key] !== newValue?.[key]) { 29 | updatedKeys.push(key); 30 | } 31 | } 32 | for (const key in newValue) { 33 | if (oldValue?.[key] !== newValue[key]) { 34 | updatedKeys.push(key); 35 | } 36 | } 37 | if (updatedKeys.length > 0) { 38 | void Persistor.resync(); 39 | if (ENABLE_STORAGE_LOGGING) { 40 | // eslint-disable-next-line no-console 41 | console.log( 42 | 'Storage keys updated:', 43 | updatedKeys, 44 | 'oldValue:', 45 | oldValue, 46 | 'newValue:', 47 | newValue 48 | ); 49 | } 50 | } 51 | }; 52 | return acc; 53 | }, {} as StorageCallbackMap); 54 | 55 | new Storage({ 56 | area: 'local', 57 | }).watch(watchObj); 58 | -------------------------------------------------------------------------------- /src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, StoreEnhancer } from '@reduxjs/toolkit'; 2 | import { devToolsEnhancer } from '@redux-devtools/remote'; 3 | import { 4 | FLUSH, 5 | PAUSE, 6 | PERSIST, 7 | persistStore, 8 | PURGE, 9 | REGISTER, 10 | REHYDRATE, 11 | RESYNC, 12 | } from '@plasmohq/redux-persist'; 13 | import middlewares from './middleware'; 14 | import persistedRootReducer, { mockRootReducer } from './rootReducer'; 15 | import Persistor from 'lib/Persistor'; 16 | 17 | const enhancers: Array = []; 18 | if (process.env.NODE_ENV === 'development') { 19 | enhancers.push(devToolsEnhancer({})); 20 | } 21 | 22 | // Until persistReducer is fixed, we need to use this mock store to get the types 23 | export const mockStore = configureStore({ 24 | reducer: mockRootReducer, 25 | }); 26 | 27 | export const store = configureStore({ 28 | reducer: persistedRootReducer, 29 | middleware: (getDefaultMiddleware) => 30 | getDefaultMiddleware({ 31 | serializableCheck: { 32 | ignoredActions: [ 33 | FLUSH, 34 | REHYDRATE, 35 | PAUSE, 36 | PERSIST, 37 | PURGE, 38 | REGISTER, 39 | RESYNC, 40 | ], 41 | }, 42 | }).prepend(middlewares), 43 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat(enhancers), 44 | }); 45 | 46 | Persistor.setStore(persistStore(store)); 47 | 48 | export type RootState = ReturnType; 49 | 50 | export type AppDispatch = typeof mockStore.dispatch; 51 | 52 | export default store; 53 | -------------------------------------------------------------------------------- /src/background/Connections.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStorage } from '@plasmohq/storage'; 2 | import { confirmTabIdAlive } from './messaging'; 3 | import Storage from './Storage'; 4 | import { log } from 'lib/logging'; 5 | import { LogLoc } from 'lib/types'; 6 | 7 | type connectedTabIds = Set; 8 | 9 | class Connections { 10 | static #instance: Connections; 11 | #storage: BaseStorage; 12 | #connectedTabIds: connectedTabIds; 13 | 14 | private constructor(storage: BaseStorage) { 15 | if (Connections.#instance) { 16 | throw new Error( 17 | '[BGSW] Use MasterWSTabId.Instance() instead of new.' 18 | ); 19 | } 20 | Connections.#instance = this; 21 | this.#storage = storage; 22 | this.#connectedTabIds = new Set([]); 23 | } 24 | 25 | public static getInstance(storage: BaseStorage) { 26 | return this.#instance || (this.#instance = new this(storage)); 27 | } 28 | 29 | public async add(port: chrome.runtime.Port) { 30 | this.#connectedTabIds.add(port?.sender?.tab?.id as number); 31 | await this.#saveToStorage(); 32 | } 33 | 34 | public async remove(port: chrome.runtime.Port) { 35 | this.#connectedTabIds.delete(port?.sender?.tab?.id as number); 36 | await this.#saveToStorage(); 37 | } 38 | 39 | public has(port: chrome.runtime.Port) { 40 | return this.#connectedTabIds.has(port?.sender?.tab?.id as number); 41 | } 42 | 43 | async #saveToStorage() { 44 | await this.#storage.set( 45 | 'connectedTabIds', 46 | Array.from(this.#connectedTabIds) 47 | ); 48 | } 49 | 50 | public async loadFromStorage() { 51 | const loadedConnectedTabIds = 52 | await this.#storage.get>('connectedTabIds'); 53 | if (loadedConnectedTabIds && Array.isArray(loadedConnectedTabIds)) { 54 | for (const tabId of loadedConnectedTabIds) { 55 | const isAlive = await confirmTabIdAlive(tabId); 56 | if (isAlive) { 57 | this.#connectedTabIds.add(tabId); 58 | log( 59 | LogLoc.BGSW, 60 | 'Confirmed alive from storage re-add for tab', 61 | tabId 62 | ); 63 | } else { 64 | this.#connectedTabIds.delete(tabId); 65 | } 66 | } 67 | await this.#saveToStorage(); 68 | } 69 | } 70 | 71 | public get tabIds() { 72 | return Array.from(this.#connectedTabIds); 73 | } 74 | 75 | public get size() { 76 | return this.#connectedTabIds.size; 77 | } 78 | } 79 | 80 | export default Connections.getInstance(Storage); 81 | -------------------------------------------------------------------------------- /src/background/MasterWS.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStorage } from '@plasmohq/storage'; 2 | import { sendMsgToTab, confirmTabIdAlive } from './messaging'; 3 | import { MsgToTabType, LogLoc } from '../lib/types'; 4 | import Storage from './Storage'; 5 | import { log } from 'lib/logging'; 6 | 7 | type masterWS = number | null; 8 | 9 | class MasterWS { 10 | static #instance: MasterWS; 11 | #value: masterWS; 12 | #storage: BaseStorage; 13 | 14 | private constructor(storage: BaseStorage) { 15 | if (MasterWS.#instance) { 16 | throw new Error( 17 | '[BSGW] Use MasterWSTabId.getInstance() instead of new.' 18 | ); 19 | } 20 | MasterWS.#instance = this; 21 | this.#value = null; 22 | this.#storage = storage; 23 | } 24 | 25 | public static getInstance(storage: BaseStorage) { 26 | return this.#instance || (this.#instance = new this(storage)); 27 | } 28 | 29 | public get val() { 30 | return this.#value; 31 | } 32 | 33 | public async set(val: masterWS) { 34 | this.#value = val; 35 | await this.#storage.set('masterWSTabId', val); 36 | } 37 | 38 | public async loadFromStorage() { 39 | const loadedMasterWSTabId = 40 | await this.#storage.get('masterWSTabId'); 41 | if (loadedMasterWSTabId) { 42 | const isAlive = await confirmTabIdAlive(loadedMasterWSTabId); 43 | if ( 44 | isAlive && 45 | typeof loadedMasterWSTabId === 'number' && 46 | !isNaN(loadedMasterWSTabId) 47 | ) { 48 | await this.set(loadedMasterWSTabId); 49 | await sendMsgToTab( 50 | MsgToTabType.SET_ROLE_MASTER, 51 | loadedMasterWSTabId 52 | ); 53 | log( 54 | LogLoc.BGSW, 55 | 'Confirmed alive from storage re-add for master tab', 56 | loadedMasterWSTabId 57 | ); 58 | } else { 59 | this.#value = null; 60 | } 61 | } 62 | } 63 | } 64 | 65 | export default MasterWS.getInstance(Storage); 66 | -------------------------------------------------------------------------------- /src/background/Storage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '@plasmohq/storage'; 2 | 3 | export default new Storage({ area: 'local' }); 4 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MsgToBGSWType, 3 | type MsgToBGSW, 4 | MsgToTabType, 5 | LogLoc, 6 | } from '../lib/types'; 7 | import { isMsgExpected, sendMsgToTab, setAsMaster } from './messaging'; 8 | import connections from './Connections'; 9 | import masterWs from './MasterWS'; 10 | import { log } from 'lib/logging'; 11 | 12 | /** 13 | * Some notes about this background script: 14 | * 1. It was written when the Manifest V3 spec was still being fixed up. In 15 | * particular, it assumes there's no way to have a persistent background 16 | * websocket connection. Therefore, it can be unloaded by the browser at any 17 | * time and has to attempt to persist app state using storage. 18 | * 2. Because the websocket can't live here, the background script has to act as 19 | * a message broker between the different new tabs, attempting to keep no 20 | * more than one websocket connection alive at any given time. 21 | * 3. It was written when the Manifest v3 spec still had odd edge cases. Hence 22 | * the very over the top management of listeners and ports. 23 | * 4. It uses ports to communicate most queries from new tabs to the background, 24 | * regular messages back and forth for the master websocket query flow. 25 | */ 26 | 27 | const searchAndFindMaster = async (requestingTabId: number) => { 28 | await masterWs.set(null); 29 | let alreadyExistingMaster; 30 | await Promise.allSettled( 31 | connections.tabIds.map(async (connectedTabId) => { 32 | const response = await sendMsgToTab( 33 | MsgToTabType.CONFIRM_YOUR_ROLE_IS_MASTER, 34 | connectedTabId 35 | ); 36 | if (response) { 37 | switch (response.type) { 38 | case MsgToBGSWType.IDENTIFY_ROLE_MASTER: 39 | alreadyExistingMaster = connectedTabId; 40 | log(LogLoc.BGSW, 'Found master WS as', connectedTabId); 41 | break; 42 | case MsgToBGSWType.IDENTIFY_ROLE_CLIENT: 43 | log(LogLoc.BGSW, 'Found client WS as', connectedTabId); 44 | break; 45 | } 46 | return connectedTabId; 47 | } 48 | return null; 49 | }) 50 | ); 51 | if (alreadyExistingMaster) { 52 | setAsMaster(alreadyExistingMaster); 53 | } else { 54 | setAsMaster(requestingTabId); 55 | } 56 | }; 57 | 58 | const figureOutMaster = async (requestingTabId: number) => { 59 | log( 60 | LogLoc.BGSW, 61 | 'Figuring out master, current connections:', 62 | connections.tabIds 63 | ); 64 | /** 65 | * If masterWs is null, either it fired onDisconnect or the background 66 | * script was reloaded and it didn't answer as alive when loaded from 67 | * storage. 68 | */ 69 | if (masterWs.val) { 70 | setAsMaster(masterWs.val); 71 | } else if (connections.size === 1) { 72 | setAsMaster(requestingTabId); 73 | } else { 74 | await searchAndFindMaster(requestingTabId); 75 | } 76 | }; 77 | 78 | const handlePortMessage = (message: MsgToBGSW, port: chrome.runtime.Port) => { 79 | if (!isMsgExpected(message, port?.sender)) return; 80 | const tabId = port?.sender?.tab?.id as number; 81 | switch (message.type) { 82 | case MsgToBGSWType.QUERY_WS_ROLE: { 83 | void figureOutMaster(tabId); 84 | break; 85 | } 86 | } 87 | }; 88 | 89 | const handlePortDisconnect = (port: chrome.runtime.Port) => { 90 | log(LogLoc.BGSW, 'Disconnecting port:', port?.sender?.tab?.id); 91 | port.onMessage.removeListener(handlePortMessage); 92 | port.onDisconnect.removeListener(handlePortDisconnect); 93 | void connections.remove(port); 94 | if (port?.sender?.tab?.id === masterWs.val) { 95 | void masterWs.set(null); 96 | if (connections.size >= 1) { 97 | const newRequestingTabId = connections.tabIds[0]; 98 | void setAsMaster(newRequestingTabId); 99 | } 100 | } 101 | }; 102 | 103 | const handlePortConnect = (port: chrome.runtime.Port) => { 104 | if (port.name === 'ws' && port?.sender?.tab?.id && !connections.has(port)) { 105 | log(LogLoc.BGSW, 'Connecting port:', port.sender.tab.id); 106 | if (!port.onMessage.hasListener(handlePortMessage)) { 107 | port.onMessage.addListener(handlePortMessage); 108 | } 109 | if (!port.onDisconnect.hasListener(handlePortDisconnect)) { 110 | port.onDisconnect.addListener(handlePortDisconnect); 111 | } 112 | void connections.add(port); 113 | } 114 | return true; 115 | }; 116 | 117 | if (!chrome.runtime.onConnect.hasListener(handlePortConnect)) { 118 | chrome.runtime.onConnect.addListener(handlePortConnect); 119 | } 120 | 121 | // Load connections from storage, should be run if the BGSW is reloaded 122 | void connections.loadFromStorage(); 123 | void masterWs.loadFromStorage(); 124 | -------------------------------------------------------------------------------- /src/background/messaging.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MsgToBGSWType, 3 | type MsgToBGSW, 4 | MsgToTabType, 5 | MsgDirection, 6 | type MsgToTab, 7 | getMsgToTabType, 8 | getMsgToBGSWType, 9 | LogLoc, 10 | LogMsgDir, 11 | } from '../lib/types'; 12 | import { logMsg, log, logMsgErr } from 'lib/logging'; 13 | import connections from './Connections'; 14 | import masterWs from './MasterWS'; 15 | 16 | const TIME_TO_WAIT_FOR_TAB_ALIVE_RESPONSE = 500; 17 | 18 | export const isMsgExpected = ( 19 | message: MsgToBGSW, 20 | sender?: chrome.runtime.MessageSender 21 | ): message is MsgToBGSW => { 22 | if (!sender?.tab?.id) { 23 | logMsgErr(LogLoc.BGSW, LogMsgDir.RECV, 'No tab ID found in message'); 24 | return false; 25 | } 26 | if ( 27 | !message || 28 | typeof message !== 'object' || 29 | !('direction' in message) || 30 | !('type' in message) 31 | ) { 32 | logMsgErr( 33 | LogLoc.BGSW, 34 | LogMsgDir.RECV, 35 | 'Invalid message recv:', 36 | message 37 | ); 38 | return false; 39 | } 40 | if (message.direction !== MsgDirection.TO_BGSW) { 41 | return false; 42 | } 43 | logMsg( 44 | LogLoc.BGSW, 45 | LogMsgDir.RECV, 46 | 'Data recv from', 47 | sender.tab.id, 48 | getMsgToBGSWType(message.type) 49 | ); 50 | return true; 51 | }; 52 | 53 | export const sendMsgToTab = async (type: MsgToTabType, tabId: number) => { 54 | logMsg( 55 | LogLoc.BGSW, 56 | LogMsgDir.SEND, 57 | 'Sending message to ', 58 | tabId, 59 | getMsgToTabType(type) 60 | ); 61 | const response = await chrome.tabs.sendMessage(tabId, { 62 | direction: MsgDirection.TO_NEWTAB, 63 | type, 64 | }); 65 | if (response) { 66 | if (response.direction !== MsgDirection.TO_BGSW) { 67 | throw new Error( 68 | '[BGSW] <= Invalid response direction', 69 | response.direction 70 | ); 71 | } 72 | if (!response.type) { 73 | throw new Error('[BGSW] <= Invalid response type', response.type); 74 | } 75 | logMsg( 76 | LogLoc.BGSW, 77 | LogMsgDir.RECV, 78 | 'Response recv from ', 79 | tabId, 80 | getMsgToBGSWType(response.type) 81 | ); 82 | return response; 83 | } 84 | return; 85 | }; 86 | 87 | const waitForTimeout = (tabId: number) => { 88 | return new Promise((resolve) => { 89 | setTimeout(() => { 90 | log( 91 | LogLoc.BGSW, 92 | 'Timed out waiting for alive response from', 93 | tabId 94 | ); 95 | resolve(null); 96 | }, TIME_TO_WAIT_FOR_TAB_ALIVE_RESPONSE); 97 | }); 98 | }; 99 | 100 | export const confirmTabIdAlive = async (tabId: number) => { 101 | try { 102 | const tab = await chrome.tabs.get(tabId); 103 | if (tab.active && !tab.discarded && tab.status === 'complete') { 104 | const response = await Promise.race([ 105 | waitForTimeout(tabId), 106 | sendMsgToTab(MsgToTabType.QUERY_ALIVE, tabId), 107 | ]); 108 | if (response && response?.type === MsgToBGSWType.CONFIRMING_ALIVE) { 109 | return true; 110 | } 111 | } 112 | } catch (err) { 113 | return false; 114 | } 115 | return false; 116 | }; 117 | 118 | export const sendToGivenTabs = async ( 119 | type: MsgToTabType, 120 | tabIds: Array 121 | ) => { 122 | await Promise.allSettled(tabIds.map((tabId) => sendMsgToTab(type, tabId))); 123 | }; 124 | 125 | const setAsClients = async (clientTabIds: Array) => { 126 | await sendToGivenTabs(MsgToTabType.SET_ROLE_CLIENT, clientTabIds); 127 | }; 128 | 129 | export const setAsMaster = (masterTabId: number) => { 130 | log(LogLoc.BGSW, 'Setting master websocket:', masterTabId); 131 | const clientTabs = connections.tabIds.filter( 132 | (tabId) => tabId !== masterTabId 133 | ); 134 | void Promise.all([ 135 | masterWs.set(masterTabId), 136 | sendMsgToTab(MsgToTabType.SET_ROLE_MASTER, masterTabId), 137 | setAsClients(clientTabs), 138 | ]); 139 | }; 140 | -------------------------------------------------------------------------------- /src/components/BehaviorPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from 'components/Button'; 2 | import * as styles from './style.module.css'; 3 | import { useAppDispatch, useAppSelector } from 'app/hooks'; 4 | import { selectedMatchQuery, setMatchQueryTo } from 'modules/emacs/emacsSlice'; 5 | import { selectedStateResolved } from 'modules/role/roleSlice'; 6 | import { selectedWSPort, setWSPortTo } from 'modules/ws/wsSlice'; 7 | import { useCallback, useEffect, useRef } from 'react'; 8 | 9 | const BehaviorPanel: React.FC = () => { 10 | const dispatch = useAppDispatch(); 11 | const matchQuery = useAppSelector(selectedMatchQuery); 12 | const wsPort = useAppSelector(selectedWSPort); 13 | const matchQueryInputRef = useRef(null); 14 | const wsPortInputRef = useRef(null); 15 | const isInitialStateResolved = useAppSelector(selectedStateResolved); 16 | 17 | const handleFormSubmit = useCallback( 18 | (event: React.FormEvent) => { 19 | event.preventDefault(); 20 | const { currentTarget } = event; 21 | const data = new FormData(currentTarget); 22 | const formMatchQuery = data.get('matchQuery'); 23 | if (formMatchQuery && typeof formMatchQuery === 'string') { 24 | dispatch(setMatchQueryTo(formMatchQuery)); 25 | } 26 | const formWSPort = data.get('wsPort'); 27 | if (formWSPort && typeof formWSPort === 'string') { 28 | const portNumber = parseInt(formWSPort, 10); 29 | if ( 30 | !isNaN(portNumber) && 31 | portNumber > 0 && 32 | portNumber < 65536 && 33 | portNumber !== wsPort 34 | ) { 35 | dispatch(setWSPortTo(portNumber)); 36 | } 37 | } 38 | }, 39 | [dispatch, wsPort] 40 | ); 41 | 42 | useEffect(() => { 43 | if (matchQueryInputRef.current && matchQuery) { 44 | matchQueryInputRef.current.value = matchQuery; 45 | } 46 | }, [isInitialStateResolved, matchQuery]); 47 | 48 | useEffect(() => { 49 | if (wsPortInputRef.current && wsPort) { 50 | wsPortInputRef.current.value = wsPort.toString(); 51 | } 52 | }, [isInitialStateResolved, wsPort]); 53 | 54 | return ( 55 |
56 | 59 |
60 | 69 | ? 70 | 76 |
77 | 80 |
81 | 90 | ? 91 | 97 |
98 | 106 |
107 | ); 108 | }; 109 | 110 | export default BehaviorPanel; 111 | -------------------------------------------------------------------------------- /src/components/BehaviorPanel/style.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: grid; 3 | grid-template-columns: 1fr; 4 | grid-auto-rows: 1fr 6fr; 5 | place-items: baseline start; 6 | height: fit-content; 7 | position: relative; 8 | width: auto; 9 | margin: 2rem; 10 | } 11 | 12 | @media (width >= 50rem) { 13 | .form { 14 | width: max(50vw, 45rem); 15 | margin: 2rem 5rem; 16 | } 17 | } 18 | 19 | .label { 20 | font-weight: var(--font-weight-bold); 21 | font-size: 0.875rem; 22 | text-transform: uppercase; 23 | letter-spacing: 0.29em; 24 | word-spacing: 0.29em; 25 | align-self: end; 26 | margin-bottom: 0.2rem; 27 | } 28 | 29 | .input { 30 | width: 100%; 31 | font-family: var(--font-mono); 32 | font-weight: var(--font-weight-light); 33 | box-sizing: border-box; 34 | border: 2px solid var(--color-item-shadow); 35 | background: var(--color-options-form-input); 36 | box-shadow: 4px 4px 0 0 var(--color-options-form-input-box-shadow) inset; 37 | height: 3rem; 38 | font-size: 1.7rem; 39 | padding: 0.5rem 2.2rem 0.5rem 0.5rem; 40 | transition: all 0.1s ease-in; 41 | } 42 | 43 | .input:focus, 44 | .input:active { 45 | outline: none; 46 | box-shadow: 4px 4px 0 0 var(--color-item-shadow); 47 | } 48 | 49 | .input-container { 50 | width: 100%; 51 | position: relative; 52 | transition: all 0.1s ease-in; 53 | } 54 | 55 | .input-container .input:focus, 56 | .input-container .input:active, 57 | .input-container .input:focus ~ .tooltip, 58 | .input-container .input:active ~ .tooltip { 59 | transform: translate(-0.1rem, -0.1rem); 60 | } 61 | 62 | .tooltip { 63 | position: absolute; 64 | right: 0; 65 | top: 0.35rem; 66 | font-size: 2rem; 67 | opacity: 0.5; 68 | padding: 0.1rem 0.7rem 0.1rem 0.5rem; 69 | cursor: help; 70 | transition: all 0.1s ease-in; 71 | } 72 | 73 | .tooltip-text { 74 | visibility: hidden; 75 | max-width: 16rem; 76 | border: 2px solid var(--color-item-shadow); 77 | text-align: center; 78 | padding: 1rem; 79 | position: absolute; 80 | z-index: 255; 81 | top: 1.5rem; 82 | right: 0.7rem; 83 | font-size: 1rem; 84 | font-weight: var(--font-weight-light); 85 | background-color: white; 86 | opacity: 0; 87 | transition: all 0.2s ease-in; 88 | cursor: help; 89 | } 90 | 91 | .tooltip:hover + .tooltip-text, 92 | .tooltip-text:hover { 93 | visibility: visible; 94 | opacity: 1; 95 | transform: translate(0.1em, 0.1em); 96 | transform: translateZ(0); 97 | } 98 | 99 | .tooltip-text a { 100 | color: var(--color-item-shadow); 101 | font-weight: var(--font-weight-standard); 102 | text-underline-offset: 0.2em; 103 | } 104 | 105 | .tooltip-text code, 106 | .tooltip-text pre { 107 | font-weight: var(--font-weight-standard); 108 | cursor: text; 109 | word-break: break-word; 110 | } 111 | 112 | .tooltip-text a:hover { 113 | text-decoration-thickness: 2px; 114 | } 115 | 116 | .button { 117 | grid-row: span 2; 118 | place-self: center center; 119 | } 120 | -------------------------------------------------------------------------------- /src/components/BehaviorPanel/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "form": string; 3 | readonly "label": string; 4 | readonly "input": string; 5 | readonly "input-container": string; 6 | readonly "tooltip": string; 7 | readonly "tooltip-text": string; 8 | readonly "button": string; 9 | }; 10 | export = styles; 11 | 12 | -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as styles from './style.module.css'; 3 | 4 | interface ButtonProps extends React.ButtonHTMLAttributes { 5 | styleType: 'primary' | 'reset'; 6 | small?: boolean; 7 | } 8 | 9 | const Button: React.FC = ({ 10 | styleType, 11 | small, 12 | children, 13 | className, 14 | ...props 15 | }) => { 16 | return ( 17 | 27 | ); 28 | }; 29 | 30 | export default Button; 31 | -------------------------------------------------------------------------------- /src/components/Button/style.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | --scale: 1; 3 | 4 | padding: 0.85rem 3rem; 5 | cursor: pointer; 6 | border: 2px solid var(--color-item-shadow); 7 | box-shadow: 0.3rem 0.3rem 0 0 var(--color-item-shadow); 8 | border-radius: 0.4rem; 9 | font-size: 1.5rem; 10 | font-weight: var(--font-weight-standard); 11 | text-align: center; 12 | transition: all 0.1s ease-in; 13 | transform: scale(var(--scale)); 14 | } 15 | 16 | .button:hover { 17 | transform: translate(-0.05rem, -0.05rem) scale(var(--scale)); 18 | box-shadow: 0.3em 0.3em; 19 | background: white; 20 | padding: 0.87rem 3.1rem; 21 | } 22 | 23 | .button:active { 24 | transform: translate(0.1em, 0.1em) scale(var(--scale)); 25 | box-shadow: 0.15em 0.15em; 26 | background: white; 27 | transition: all 0.03s ease-in; 28 | padding: 0.87rem 3.1rem; 29 | } 30 | 31 | .primary { 32 | background: linear-gradient(180deg, #84fab0 0%, #8fd3f4 100%); 33 | } 34 | 35 | .reset { 36 | background: linear-gradient(180deg, #868f96 0%, #596164 100%); 37 | color: white; 38 | } 39 | 40 | .reset:hover { 41 | color: black; 42 | } 43 | 44 | .small { 45 | --scale: 0.75; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Button/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "button": string; 3 | readonly "primary": string; 4 | readonly "reset": string; 5 | readonly "small": string; 6 | }; 7 | export = styles; 8 | 9 | -------------------------------------------------------------------------------- /src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | 3 | type CheckboxProps = { 4 | label: string; 5 | }; 6 | 7 | const Checkbox: React.FC = ({ label }) => { 8 | return ( 9 | 17 | ); 18 | }; 19 | 20 | export default Checkbox; 21 | -------------------------------------------------------------------------------- /src/components/Checkbox/style.module.css: -------------------------------------------------------------------------------- 1 | .label { 2 | display: flex; 3 | align-items: center; 4 | flex-flow: row wrap; 5 | text-wrap: nowrap; 6 | font-size: 1rem; 7 | cursor: pointer; 8 | } 9 | 10 | .checkbox { 11 | position: relative !important; 12 | appearance: none; 13 | margin: 8px; 14 | box-sizing: content-box; 15 | overflow: hidden; 16 | cursor: pointer; 17 | transform: translate(-2px, -2px); 18 | box-shadow: 2px 2px 0 0 var(--color-item-shadow); 19 | } 20 | 21 | .checkbox:active, 22 | .label:active .checkbox { 23 | transform: none; 24 | box-shadow: none; 25 | } 26 | 27 | .checkbox::before { 28 | content: ''; 29 | display: block; 30 | box-sizing: content-box; 31 | width: 1rem; 32 | height: 1rem; 33 | border: 2px solid var(--color-item-shadow); 34 | border-radius: 0; 35 | transition: 36 | 0.2s border-color ease, 37 | 0.2s background-color ease; 38 | background-color: transparent; 39 | } 40 | 41 | .checkbox:checked::before { 42 | transition: 0.5s border-color ease; 43 | } 44 | 45 | .checkbox::after { 46 | content: ''; 47 | display: block; 48 | position: absolute; 49 | box-sizing: content-box; 50 | top: 50%; 51 | left: 50%; 52 | transform-origin: 50% 50%; 53 | width: 0.6rem; 54 | height: 1rem; 55 | border-radius: 0; 56 | transform: translate(-50%, -85%) scale(0) rotate(45deg); 57 | background-color: transparent; 58 | box-shadow: 4px 4px 0 0 var(--color-item-shadow); 59 | } 60 | 61 | .checkbox:checked::after { 62 | animation: toggle-on-checkbox 0.2s ease forwards; 63 | } 64 | 65 | @keyframes toggle-on-checkbox { 66 | 0% { 67 | opacity: 0; 68 | transform: translate(-50%, -85%) scale(0) rotate(45deg); 69 | } 70 | 71 | 70% { 72 | opacity: 1; 73 | transform: translate(-50%, -85%) scale(0.9) rotate(45deg); 74 | } 75 | 76 | 100% { 77 | transform: translate(-50%, -85%) scale(0.8) rotate(45deg); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/Checkbox/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "label": string; 3 | readonly "checkbox": string; 4 | readonly "toggle-on-checkbox": string; 5 | }; 6 | export = styles; 7 | 8 | -------------------------------------------------------------------------------- /src/components/ConnectionStatusIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { useAppSelector } from '../../app/hooks'; 3 | import { WSReadyState } from '../../lib/types'; 4 | import { selectedReadyState } from 'modules/ws/wsSlice'; 5 | 6 | const ConnectionStatusIndicator: React.FC = () => { 7 | const readyState = useAppSelector(selectedReadyState); 8 | const connectionStatus = { 9 | [WSReadyState.CONNECTING]: chrome.i18n.getMessage( 10 | 'connectionStatusConnecting' 11 | ), 12 | [WSReadyState.OPEN]: chrome.i18n.getMessage('connectionStatusOpen'), 13 | [WSReadyState.CLOSING]: chrome.i18n.getMessage( 14 | 'connectionStatusClosing' 15 | ), 16 | [WSReadyState.CLOSED]: chrome.i18n.getMessage('connectionStatusClosed'), 17 | [WSReadyState.UNINSTANTIATED]: chrome.i18n.getMessage( 18 | 'connectionStatusUninstantiated' 19 | ), 20 | }[readyState]; 21 | 22 | return ( 23 |
24 | {connectionStatus} 25 |
26 | ); 27 | }; 28 | 29 | export default ConnectionStatusIndicator; 30 | -------------------------------------------------------------------------------- /src/components/ConnectionStatusIndicator/style.module.css: -------------------------------------------------------------------------------- 1 | .status { 2 | margin: 0.25rem; 3 | font-size: 0.85rem; 4 | opacity: var(--opacity-ui); 5 | color: var(--color-ui); 6 | font-weight: var(--font-weight-light); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/ConnectionStatusIndicator/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "status": string; 3 | }; 4 | export = styles; 5 | 6 | -------------------------------------------------------------------------------- /src/components/DebugPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from 'app/hooks'; 2 | import * as styles from './style.module.css'; 3 | import { 4 | selectedAmMasterRole, 5 | selectedStateResolved, 6 | } from 'modules/role/roleSlice'; 7 | import { selectedTagsData } from 'modules/emacs/emacsSlice'; 8 | import Button from '../Button'; 9 | import { useCallback } from 'react'; 10 | import { resetData } from 'app/actions'; 11 | 12 | const DebugPanel: React.FC = () => { 13 | const isInitialStateResolved = useAppSelector(selectedStateResolved); 14 | const amMasterRole = useAppSelector(selectedAmMasterRole); 15 | const tagsData = useAppSelector(selectedTagsData); 16 | const dispatch = useAppDispatch(); 17 | const masterStatus = amMasterRole 18 | ? chrome.i18n.getMessage('masterRole') 19 | : chrome.i18n.getMessage('clientRole'); 20 | 21 | const onResetClick = useCallback(() => { 22 | dispatch(resetData()); 23 | }, [dispatch]); 24 | 25 | return ( 26 |
27 |
28 | 29 | {chrome.i18n.getMessage('storageStatus')}:{' '} 30 | 31 | {isInitialStateResolved 32 | ? chrome.i18n.getMessage('storageResolved') 33 | : chrome.i18n.getMessage('storageUnresolved')} 34 |
35 |
36 | 37 | {chrome.i18n.getMessage('websocketRole')}:{' '} 38 | 39 | {masterStatus} 40 |
41 |
42 | 43 | {chrome.i18n.getMessage('tagsData')}:{' '} 44 | 45 |
{JSON.stringify(tagsData, null, 2)}
46 |
47 | 54 |
60 |
61 | ); 62 | }; 63 | 64 | export default DebugPanel; 65 | -------------------------------------------------------------------------------- /src/components/DebugPanel/style.module.css: -------------------------------------------------------------------------------- 1 | .panel { 2 | width: auto; 3 | display: grid; 4 | justify-content: center; 5 | margin: 2rem; 6 | } 7 | 8 | @media (width >= 50rem) { 9 | .panel { 10 | width: min(50vw, 50rem); 11 | } 12 | } 13 | 14 | .item { 15 | font-size: 1rem; 16 | text-align: left; 17 | font-weight: var(--font-weight-light); 18 | } 19 | 20 | .name { 21 | font-weight: var(--font-weight-standard); 22 | } 23 | 24 | .item pre { 25 | background: white; 26 | word-break: break-word; 27 | width: fit-content; 28 | padding: 1rem; 29 | } 30 | 31 | .reset { 32 | margin-top: 3rem; 33 | } 34 | 35 | .pitch { 36 | background: white; 37 | font-weight: var(--font-weight-light); 38 | width: calc(100% - 3.5rem); 39 | padding: 1rem; 40 | border-radius: 1rem; 41 | margin-top: 5rem; 42 | box-shadow: 0.4rem 0.4rem 0 0 var(--color-item-shadow); 43 | border: 2px solid var(--color-item-shadow); 44 | place-self: center; 45 | } 46 | 47 | .pitch a { 48 | font-weight: var(--font-weight-standard); 49 | color: red; 50 | } 51 | 52 | @media (width >= 50rem) { 53 | .pitch { 54 | width: min(30vw, 30rem); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/DebugPanel/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "panel": string; 3 | readonly "item": string; 4 | readonly "name": string; 5 | readonly "reset": string; 6 | readonly "pitch": string; 7 | }; 8 | export = styles; 9 | 10 | -------------------------------------------------------------------------------- /src/components/LayoutPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import React, { forwardRef, useCallback, useMemo, useState } from 'react'; 3 | import { 4 | useDraggable, 5 | DndContext, 6 | DraggableSyntheticListeners, 7 | DragEndEvent, 8 | useDroppable, 9 | DragOverlay, 10 | DragStartEvent, 11 | } from '@dnd-kit/core'; 12 | import { createPortal } from 'react-dom'; 13 | import classNames from 'classnames'; 14 | import { 15 | Area, 16 | LayoutState, 17 | resetLayout, 18 | selectedWidgetsInArea, 19 | setWidgetAreaTo, 20 | } from 'modules/layout/layoutSlice'; 21 | import { useAppDispatch, useAppSelector } from 'app/hooks'; 22 | import Button from 'components/Button'; 23 | import { RxDragHandleDots1 } from 'react-icons/rx'; 24 | import Icon from 'lib/Icon'; 25 | 26 | type WidgetName = keyof LayoutState; 27 | 28 | const WidgetTextMap: Record = { 29 | connectionStatus: chrome.i18n.getMessage('layoutWidgetConnectionStatus'), 30 | orgItem: chrome.i18n.getMessage('layoutWidgetOrgItem'), 31 | }; 32 | 33 | interface WidgetProps { 34 | name: WidgetName; 35 | dragOverlay?: boolean; 36 | dragging?: boolean; 37 | listeners?: DraggableSyntheticListeners; 38 | style?: React.CSSProperties; 39 | } 40 | 41 | const Widget = forwardRef(function Draggable( 42 | { name, listeners, style, dragOverlay, dragging, ...props }, 43 | ref 44 | ) { 45 | return ( 46 |
55 | 74 |
75 | ); 76 | }); 77 | 78 | interface DraggableWidgetProps { 79 | name: WidgetName; 80 | } 81 | 82 | const DraggableWidget: React.FC = ({ name }) => { 83 | const { isDragging, setNodeRef, listeners } = useDraggable({ id: name }); 84 | 85 | const memoizedStyle = useMemo(() => { 86 | return { 87 | opacity: isDragging ? 0 : undefined, 88 | }; 89 | }, [isDragging]); 90 | 91 | return ( 92 | 99 | ); 100 | }; 101 | interface DropArea { 102 | dragging: boolean; 103 | area: Area; 104 | } 105 | 106 | const DropArea: React.FC = ({ area, dragging }) => { 107 | const widgetsInArea = useAppSelector((state) => 108 | selectedWidgetsInArea(state, area) 109 | ); 110 | const { isOver, setNodeRef } = useDroppable({ 111 | id: area, 112 | }); 113 | 114 | const areaHasChildren = useCallback( 115 | () => widgetsInArea.length > 0, 116 | [widgetsInArea] 117 | ); 118 | 119 | return ( 120 |
131 | {widgetsInArea.map((widget) => ( 132 | 133 | ))} 134 |
135 | ); 136 | }; 137 | 138 | type WidgetOverlayProps = { 139 | isDragging: boolean; 140 | name: WidgetName; 141 | }; 142 | 143 | const WidgetOverlay: React.FC = ({ isDragging, name }) => { 144 | return createPortal( 145 | 146 | {isDragging ? : null} 147 | , 148 | document.body 149 | ); 150 | }; 151 | 152 | const LayoutPanel: React.FC = () => { 153 | const [isDragging, setIsDragging] = useState(false); 154 | const [draggingId, setDraggingId] = useState(null); 155 | const dispatch = useAppDispatch(); 156 | 157 | const handleDragStart = useCallback( 158 | ({ active: { id } }: DragStartEvent) => { 159 | setDraggingId(id as WidgetName); 160 | setIsDragging(true); 161 | }, 162 | [] 163 | ); 164 | 165 | const handleDragEnd = useCallback( 166 | ({ over, active: { id } }: DragEndEvent) => { 167 | setIsDragging(false); 168 | setDraggingId(null); 169 | if (over) { 170 | dispatch( 171 | setWidgetAreaTo({ 172 | widget: id as WidgetName, 173 | area: over.id as Area, 174 | }) 175 | ); 176 | } 177 | }, 178 | [dispatch] 179 | ); 180 | 181 | const handleDragCancel = useCallback(() => { 182 | setIsDragging(false); 183 | setDraggingId(null); 184 | }, []); 185 | 186 | const handleReset = useCallback(() => { 187 | dispatch(resetLayout()); 188 | }, [dispatch]); 189 | 190 | return ( 191 | 196 |
197 |
198 |

199 | {chrome.i18n.getMessage('layoutActive')} 200 | {':'} 201 |

202 |
203 | 208 |
209 |
210 | 215 |
216 |
217 | 222 |
223 |
224 |
225 |

226 | {chrome.i18n.getMessage('layoutInactive')} 227 | {':'} 228 |

229 |
230 | 235 |
236 |
237 |
238 | 245 |
246 | 250 |
251 |
252 | ); 253 | }; 254 | 255 | export default LayoutPanel; 256 | -------------------------------------------------------------------------------- /src/components/LayoutPanel/style.module.css: -------------------------------------------------------------------------------- 1 | .maps { 2 | display: grid; 3 | margin: 5rem auto; 4 | grid-gap: 2rem; 5 | width: 100%; 6 | grid-template: 7 | 'activeMap' 5fr 8 | 'inactiveMap' 3fr 9 | 'controls' 1fr / 1fr; 10 | } 11 | 12 | @media (width >= 45rem) { 13 | .maps { 14 | width: max(50vw, 50rem); 15 | grid-template: 16 | 'activeMap inactiveMap' 1fr 17 | 'activeMap inactiveMap' 1fr 18 | 'activeMap controls' 1fr / 5fr 3fr; 19 | } 20 | } 21 | 22 | .active-map { 23 | height: 100%; 24 | width: auto; 25 | grid-area: activeMap; 26 | border: 2px solid var(--color-item-shadow); 27 | background: linear-gradient(180deg, #51565a 0%, #262b2d 100%); 28 | box-shadow: 0.25rem 0.25rem 0 0 var(--color-item-shadow); 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: space-between; 32 | align-items: center; 33 | color: white; 34 | } 35 | 36 | .inactive-map { 37 | height: 100%; 38 | width: auto; 39 | grid-area: inactiveMap; 40 | border: 2px solid var(--color-item-shadow); 41 | background: linear-gradient(180deg, #f5f5f5 0%, #bebebe 100%); 42 | box-shadow: 0.25rem 0.25rem 0 0 var(--color-item-shadow); 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | } 47 | 48 | .map-label { 49 | font-size: 0.875rem; 50 | font-weight: var(--font-weight-bold); 51 | letter-spacing: 0.29em; 52 | word-spacing: 0.29em; 53 | text-transform: uppercase; 54 | margin: 1.5rem 2.5rem 0.5rem; 55 | place-self: baseline; 56 | color: #838383; 57 | } 58 | 59 | .controls { 60 | grid-area: controls; 61 | display: flex; 62 | flex-direction: column; 63 | align-items: center; 64 | justify-content: center; 65 | } 66 | 67 | .area { 68 | text-align: center; 69 | box-sizing: border-box; 70 | padding: 0 2.5rem; 71 | margin: 2.5rem 0; 72 | width: 100%; 73 | min-height: 10rem; 74 | position: relative; 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | } 79 | 80 | .area-drop-zone { 81 | position: relative; 82 | box-sizing: border-box; 83 | width: 100%; 84 | height: 100%; 85 | z-index: 301; 86 | display: flex; 87 | justify-content: center; 88 | align-items: center; 89 | flex-direction: column; 90 | gap: 1rem; 91 | border-radius: 0.3rem; 92 | background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='5' ry='5' stroke='%23838383' stroke-width='4' stroke-dasharray='10' stroke-dashoffset='5' stroke-linecap='round'/%3e%3c/svg%3e"); 93 | min-width: 15rem; 94 | } 95 | 96 | .area-drop-zone.dropped { 97 | background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='5' ry='5' stroke='white' stroke-width='4' stroke-dasharray='10' stroke-dashoffset='5' stroke-linecap='round'/%3e%3c/svg%3e"); 98 | padding: 1.5rem 0; 99 | height: auto; 100 | } 101 | 102 | .area-drop-zone.over { 103 | background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='5' ry='5' stroke='red' stroke-width='4' stroke-dasharray='10' stroke-dashoffset='5' stroke-linecap='round'/%3e%3c/svg%3e"); 104 | } 105 | 106 | .active-map .area:first-of-type, 107 | .inactive-map .area:first-of-type { 108 | margin-top: 0; 109 | } 110 | 111 | .inactive-map .area:first-of-type { 112 | min-height: initial; 113 | height: 100%; 114 | } 115 | 116 | .remove { 117 | border-radius: 100%; 118 | background: white; 119 | color: var(--color-item-shadow); 120 | width: 1.5rem; 121 | height: 1.5rem; 122 | position: absolute; 123 | right: -0.75rem; 124 | top: -0.75rem; 125 | font-size: 0.7rem; 126 | display: flex; 127 | justify-content: center; 128 | align-items: center; 129 | } 130 | 131 | .widget { 132 | --scale: 1; 133 | 134 | display: flex; 135 | align-items: center; 136 | flex-direction: column; 137 | justify-content: center; 138 | transition: transform 200ms ease; 139 | will-change: transform; 140 | } 141 | 142 | .widget button { 143 | appearance: none; 144 | border: none; 145 | outline: none; 146 | padding: 0; 147 | position: relative; 148 | z-index: 355; 149 | background: transparent; 150 | display: flex; 151 | align-items: center; 152 | justify-content: center; 153 | cursor: grab; 154 | transform: scale(var(--scale, 1)); 155 | } 156 | 157 | .widget button:hover { 158 | transform: translate(-0.075rem, -0.075rem); 159 | } 160 | 161 | .widget button::after { 162 | content: ''; 163 | position: absolute; 164 | z-index: -1; 165 | width: calc(100% - 2px); 166 | height: calc(100% - 2px); 167 | background: white; 168 | border: 2px solid var(--color-item-shadow); 169 | } 170 | 171 | .widget button::before { 172 | content: ''; 173 | position: absolute; 174 | z-index: -1; 175 | width: calc(100% - 2px); 176 | height: calc(100% - 2px); 177 | top: 0.2rem; 178 | left: 0.2rem; 179 | background: white; 180 | border: 2px solid var(--color-item-shadow); 181 | transform: transform 200ms ease; 182 | } 183 | 184 | .widget button:hover::before { 185 | transform: translate(0.075rem, 0.075rem); 186 | } 187 | 188 | .button-text { 189 | font-size: 1.5rem; 190 | font-weight: var(--font-weight-standard); 191 | margin: 0 0.6rem 0 0; 192 | display: flex; 193 | align-items: center; 194 | user-select: none; 195 | } 196 | 197 | .drag-icon { 198 | font-size: 1.75rem; 199 | margin: 0.25rem; 200 | vertical-align: middle; 201 | } 202 | 203 | .widget.dragging { 204 | --scale: 1.06; 205 | 206 | z-index: 1; 207 | transition: none; 208 | cursor: grabbing; 209 | } 210 | 211 | .widget.widget-overlay, 212 | .widget.dragging { 213 | animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); 214 | } 215 | 216 | @keyframes pop { 217 | 0% { 218 | transform: scale(1); 219 | } 220 | 221 | 100% { 222 | transform: scale(var(--scale)); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/components/LayoutPanel/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "maps": string; 3 | readonly "active-map": string; 4 | readonly "inactive-map": string; 5 | readonly "map-label": string; 6 | readonly "controls": string; 7 | readonly "area": string; 8 | readonly "area-drop-zone": string; 9 | readonly "dropped": string; 10 | readonly "over": string; 11 | readonly "remove": string; 12 | readonly "widget": string; 13 | readonly "button-text": string; 14 | readonly "drag-icon": string; 15 | readonly "dragging": string; 16 | readonly "widget-overlay": string; 17 | readonly "pop": string; 18 | }; 19 | export = styles; 20 | 21 | -------------------------------------------------------------------------------- /src/components/LoadingBar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { useNProgress } from '@tanem/react-nprogress'; 3 | import { useAppSelector } from '../../app/hooks'; 4 | import { selectedIsWaitingForResponse } from 'modules/ws/wsSlice'; 5 | import { useEffect, useState } from 'react'; 6 | 7 | const Progress: React.FC<{ 8 | animationDuration: number; 9 | isAnimating: boolean; 10 | }> = ({ animationDuration, isAnimating }) => { 11 | const { progress, isFinished } = useNProgress({ 12 | isAnimating, 13 | animationDuration, 14 | }); 15 | 16 | return ( 17 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | const LoadingBar: React.FC = () => { 32 | const isWaitingForResponse = useAppSelector(selectedIsWaitingForResponse); 33 | const [loadingKey, setLoadingKey] = useState(null); 34 | 35 | // Key needed to force re-render between very fast requests 36 | useEffect(() => { 37 | setLoadingKey((prev) => { 38 | if (!isWaitingForResponse) { 39 | return null; 40 | } else if (!prev) { 41 | return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); 42 | } else { 43 | return prev; 44 | } 45 | }); 46 | }, [isWaitingForResponse]); 47 | 48 | return ( 49 | 54 | ); 55 | }; 56 | 57 | export default LoadingBar; 58 | -------------------------------------------------------------------------------- /src/components/LoadingBar/style.module.css: -------------------------------------------------------------------------------- 1 | .outer { 2 | pointer-events: none; 3 | background: var(--color-loading-bar); 4 | height: 0.1rem; 5 | left: 0; 6 | position: fixed; 7 | top: 0; 8 | width: 100%; 9 | z-index: 1031; 10 | } 11 | 12 | .inner { 13 | box-shadow: 14 | 0 0 0.4rem var(--color-loading-bar), 15 | 0 0 0.2rem var(--color-loading-bar); 16 | display: block; 17 | height: 100%; 18 | opacity: 1; 19 | position: absolute; 20 | right: 0; 21 | transform: rotate(3deg) translate(0, -0.2rem); 22 | width: 100px; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/LoadingBar/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "outer": string; 3 | readonly "inner": string; 4 | }; 5 | export = styles; 6 | 7 | -------------------------------------------------------------------------------- /src/components/Options/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { memo, useCallback, useState } from 'react'; 3 | import { useAppSelector } from '../../app/hooks'; 4 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 5 | import { OptionCategories, selectedOptionCategory } from 'modules/ui/uiSlice'; 6 | import BehaviorPanel from 'components/BehaviorPanel'; 7 | import LayoutPanel from 'components/LayoutPanel'; 8 | import ThemingPanel from 'components/ThemingPanel'; 9 | import DebugPanel from 'components/DebugPanel'; 10 | import TabBar from 'components/TabBar'; 11 | import OptionsToggle from 'components/OptionsToggle'; 12 | import classNames from 'classnames'; 13 | 14 | type OptionsPanelProps = { 15 | selectedCategory: OptionCategories; 16 | }; 17 | 18 | const OptionsPanel: React.FC = ({ selectedCategory }) => { 19 | const PanelToRender = useCallback(() => { 20 | switch (selectedCategory) { 21 | case 'Behavior': 22 | return ; 23 | case 'Layout': 24 | return ; 25 | case 'Theming': 26 | return ; 27 | case 'Debug': 28 | return ; 29 | } 30 | }, [selectedCategory]); 31 | return ( 32 |
33 | 34 |
35 | ); 36 | }; 37 | 38 | const slideTransitionTimeout = 500; 39 | const slideTransitionClassNames = { 40 | enter: styles['slide-enter'], 41 | enterActive: styles['slide-enter-active'], 42 | exit: styles['slide-exit'], 43 | exitActive: styles['slide-exit-active'], 44 | }; 45 | 46 | const MemoizedCSSTransition = memo(CSSTransition, (prevProps, nextProps) => { 47 | return ( 48 | prevProps.key === nextProps.key && 49 | prevProps.in === nextProps.in && 50 | prevProps.enter === nextProps.enter && 51 | prevProps.exit === nextProps.exit 52 | ); 53 | }); 54 | 55 | const OptionsContent: React.FC = () => { 56 | const selectedCategory = useAppSelector(selectedOptionCategory); 57 | return ( 58 |
59 |
60 | 61 | 67 | 68 | 69 | 70 |
71 |
72 | ); 73 | }; 74 | 75 | const Options: React.FC = () => { 76 | const [menuVisible, setMenuVisible] = useState(false); 77 | const toggleMenu = useCallback(() => { 78 | setMenuVisible(!menuVisible); 79 | }, [menuVisible]); 80 | 81 | return ( 82 | <> 83 | 87 |
92 | 93 | 94 |
95 | 96 | ); 97 | }; 98 | 99 | export default Options; 100 | -------------------------------------------------------------------------------- /src/components/Options/style.module.css: -------------------------------------------------------------------------------- 1 | .menu { 2 | position: fixed; 3 | z-index: 200; 4 | width: 100vw; 5 | inset: 0; 6 | visibility: hidden; 7 | } 8 | 9 | .menu.is-visible { 10 | visibility: visible; 11 | } 12 | 13 | .content-container { 14 | position: relative; 15 | height: calc(100vh - 7rem); 16 | overflow: hidden; 17 | } 18 | 19 | .content { 20 | background: linear-gradient(180deg, #cfd9df 0%, #e2ebf0 100%); 21 | display: flex; 22 | width: 100%; 23 | height: 100%; 24 | place-content: center center; 25 | flex-direction: column; 26 | transform: translateY(-100%); 27 | transition: all var(--animation-duration-options) 28 | var(--animation-timing-options); 29 | } 30 | 31 | .panel { 32 | position: absolute; 33 | display: grid; 34 | width: 100%; 35 | height: inherit; 36 | align-items: center; 37 | justify-content: center; 38 | color: var(--color-options-panel-text); 39 | overflow-y: auto; 40 | } 41 | 42 | .is-visible .content { 43 | transform: translateY(0); 44 | } 45 | 46 | .slide-enter { 47 | transform: translateX(-100%); 48 | opacity: 0; 49 | } 50 | 51 | .slide-enter-active { 52 | transform: translateX(0); 53 | opacity: 1; 54 | transition: 55 | transform 0.5s ease, 56 | opacity 0.5s ease; 57 | } 58 | 59 | .slide-exit { 60 | transform: translateX(0); 61 | opacity: 1; 62 | } 63 | 64 | .slide-exit-active { 65 | transform: translateX(100%); 66 | opacity: 0; 67 | transition: 68 | transform 0.5s ease, 69 | opacity 0.5s ease; 70 | } 71 | 72 | /* TODO: vars for colors and animation durations */ 73 | -------------------------------------------------------------------------------- /src/components/Options/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "menu": string; 3 | readonly "is-visible": string; 4 | readonly "content-container": string; 5 | readonly "content": string; 6 | readonly "panel": string; 7 | readonly "slide-enter": string; 8 | readonly "slide-enter-active": string; 9 | readonly "slide-exit": string; 10 | readonly "slide-exit-active": string; 11 | }; 12 | export = styles; 13 | 14 | -------------------------------------------------------------------------------- /src/components/OptionsToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import classNames from 'classnames'; 3 | 4 | type OptionsButtonProps = { 5 | optionsVisible: boolean; 6 | toggleMenu: () => void; 7 | }; 8 | 9 | const OptionsToggle: React.FC = ({ 10 | optionsVisible, 11 | toggleMenu, 12 | }) => { 13 | return ( 14 | <> 15 | 27 | 38 | 39 | ); 40 | }; 41 | 42 | export default OptionsToggle; 43 | -------------------------------------------------------------------------------- /src/components/OptionsToggle/style.module.css: -------------------------------------------------------------------------------- 1 | .open { 2 | position: absolute; 3 | inset: 0 auto auto 0; 4 | margin: 0; 5 | padding: 1.5rem; 6 | background-color: transparent; 7 | border: none; 8 | z-index: 200; 9 | cursor: pointer; 10 | outline: none; 11 | display: grid; 12 | gap: 0.4rem 0.5rem; 13 | grid-template: 14 | 'topBarLeft topBarRight topBarRight topBarRight' 1fr 15 | 'bottomBar bottomBar bottomBar bottomBar' 1fr / 1fr 1fr 1fr 1fr; 16 | } 17 | 18 | .close { 19 | position: absolute; 20 | inset: 0 auto auto 0; 21 | margin: 0; 22 | padding: 1.75rem; 23 | background-color: transparent; 24 | border: none; 25 | z-index: 210; 26 | cursor: pointer; 27 | outline: none; 28 | opacity: 0; 29 | transition: all var(--animation-duration-options) ease; 30 | transform: rotate(-90deg); 31 | pointer-events: none; 32 | } 33 | 34 | .close.is-visible { 35 | opacity: 1; 36 | transform: rotate(0deg); 37 | pointer-events: all; 38 | } 39 | 40 | .close-bar1, 41 | .close-bar2 { 42 | height: 2px; 43 | background-color: black; 44 | width: 1.5rem; 45 | transform-origin: 0.65rem 0.1rem; 46 | } 47 | 48 | .close-bar1 { 49 | transform: rotate(45deg); 50 | } 51 | 52 | .close-bar2 { 53 | transform: rotate(-45deg); 54 | } 55 | 56 | .open-bar1, 57 | .open-bar2, 58 | .open-bar3 { 59 | height: 0.25rem; 60 | background-color: var(--color-ui); 61 | opacity: var(--opacity-ui); 62 | transition: 0.15s; 63 | } 64 | 65 | .open:hover .open-bar1 { 66 | width: 0.8rem; 67 | } 68 | 69 | .open-bar1 { 70 | width: 0.7rem; 71 | grid-area: topBarLeft; 72 | } 73 | 74 | .open-bar2 { 75 | width: 60%; 76 | grid-area: topBarRight; 77 | } 78 | 79 | .open-bar3 { 80 | width: 55%; 81 | grid-area: bottomBar; 82 | } 83 | 84 | .open.is-visible .open-bar1, 85 | .open.is-visible .open-bar2, 86 | .open.is-visible .open-bar3 { 87 | opacity: 0; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/OptionsToggle/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "open": string; 3 | readonly "close": string; 4 | readonly "is-visible": string; 5 | readonly "close-bar1": string; 6 | readonly "close-bar2": string; 7 | readonly "open-bar1": string; 8 | readonly "open-bar2": string; 9 | readonly "open-bar3": string; 10 | }; 11 | export = styles; 12 | 13 | -------------------------------------------------------------------------------- /src/components/OrgItem/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import logo from 'data-base64:~../assets/icon-1024x1024bw.png'; 3 | import { useAppSelector } from '../../app/hooks'; 4 | import { selectedIsInSync } from 'modules/ws/wsSlice'; 5 | import { selectedItemText, selectedTagColors } from 'modules/emacs/emacsSlice'; 6 | import classNames from 'classnames'; 7 | 8 | const OrgItem: React.FC = () => { 9 | const itemText = useAppSelector(selectedItemText); 10 | const tagColors = useAppSelector(selectedTagColors); 11 | const isInSync = useAppSelector(selectedIsInSync); 12 | 13 | let background: string | undefined = undefined; 14 | if (tagColors.length === 1) { 15 | background = tagColors[0]; 16 | } else if (tagColors.length > 1) { 17 | background = `linear-gradient(-45deg, ${tagColors.join(', ')})`; 18 | } 19 | 20 | return ( 21 | <> 22 | {itemText ? ( 23 |

30 | {itemText} 31 |

32 | ) : ( 33 | logo 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default OrgItem; 40 | -------------------------------------------------------------------------------- /src/components/OrgItem/style.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | max-width: calc(80vw - 6rem); 3 | background: var(--color-untagged-item-bg); 4 | padding: 2.8rem; 5 | border-radius: 2rem; 6 | border: 0.4rem solid var(--color-item-shadow); 7 | box-shadow: 1rem 1rem 0 0 var(--color-item-shadow); 8 | font-size: 2.25rem; 9 | text-shadow: 10 | calc(var(--width-item-stroke) * -1) calc(var(--width-item-stroke) * -1) 11 | 0 var(--color-item-shadow), 12 | var(--width-item-stroke) calc(var(--width-item-stroke) * -1) 0 13 | var(--color-item-shadow), 14 | calc(var(--width-item-stroke) * -1) var(--width-item-stroke) 0 15 | var(--color-item-shadow), 16 | var(--width-item-stroke) var(--width-item-stroke) 0 17 | var(--color-item-shadow); 18 | -webkit-text-stroke: calc(var(--width-item-stroke) / 1.5) 19 | var(--color-item-shadow); 20 | z-index: 10; 21 | line-height: 1.25; 22 | letter-spacing: 0.0125em; 23 | overflow-wrap: break-word; 24 | } 25 | 26 | @media (width >= 60rem) { 27 | .item { 28 | max-width: calc(50vw - 6rem); 29 | } 30 | } 31 | 32 | .item::after { 33 | content: attr(value); 34 | position: relative; 35 | left: 0; 36 | -webkit-text-stroke: 0; 37 | pointer-events: none; 38 | } 39 | 40 | .logo { 41 | position: absolute; 42 | z-index: 5; 43 | opacity: 0.4; 44 | max-width: min(50vw, 25rem); 45 | height: auto; 46 | width: auto; 47 | } 48 | 49 | .stale { 50 | opacity: 0.75; 51 | filter: contrast(0.75); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/OrgItem/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "item": string; 3 | readonly "logo": string; 4 | readonly "stale": string; 5 | }; 6 | export = styles; 7 | 8 | -------------------------------------------------------------------------------- /src/components/TabBar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { useAppDispatch, useAppSelector } from 'app/hooks'; 3 | import { 4 | OptionCategories, 5 | selectedOptionCategory, 6 | setOptCatTo, 7 | } from 'modules/ui/uiSlice'; 8 | import { memo, useCallback } from 'react'; 9 | import { 10 | LuBrainCircuit, 11 | LuLayoutDashboard, 12 | LuPaintbrush, 13 | LuCode, 14 | } from 'react-icons/lu'; 15 | import classNames from 'classnames'; 16 | import Icon from 'lib/Icon'; 17 | 18 | type OptButtonProps = { 19 | category: OptionCategories; 20 | isSelected: boolean; 21 | handleClick: ( 22 | event: React.MouseEvent 23 | ) => void; 24 | icon: React.ReactNode; 25 | }; 26 | 27 | const OptButton: React.FC = ({ 28 | category, 29 | isSelected, 30 | handleClick, 31 | icon, 32 | }) => { 33 | return ( 34 | 48 | ); 49 | }; 50 | 51 | const MemoizedOptButton = memo(OptButton, (prevProps, nextProps) => { 52 | return prevProps.isSelected === nextProps.isSelected; 53 | }); 54 | 55 | type TabBarProps = { 56 | menuVisible: boolean; 57 | }; 58 | 59 | const TabBar: React.FC = ({ menuVisible }) => { 60 | const dispatch = useAppDispatch(); 61 | const selectedCategory = useAppSelector(selectedOptionCategory); 62 | const handleCategoryClick = useCallback( 63 | (event: React.MouseEvent) => { 64 | const { currentTarget } = event; 65 | const category = currentTarget.dataset.category as OptionCategories; 66 | if (category) { 67 | dispatch(setOptCatTo(category)); 68 | } 69 | }, 70 | [dispatch] 71 | ); 72 | 73 | return ( 74 | 108 | ); 109 | }; 110 | 111 | export default TabBar; 112 | -------------------------------------------------------------------------------- /src/components/TabBar/style.module.css: -------------------------------------------------------------------------------- 1 | .bar { 2 | display: flex; 3 | justify-content: space-evenly; 4 | align-items: center; 5 | background: var(--color-ui); 6 | height: 7rem; 7 | padding-left: 5rem; 8 | transform: translateX(-100%); 9 | transition: all var(--animation-duration-options) 10 | var(--animation-timing-options); 11 | } 12 | 13 | .is-visible { 14 | transform: translateX(0); 15 | } 16 | 17 | .button { 18 | background: transparent; 19 | border: none; 20 | cursor: pointer; 21 | outline: none; 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: center; 25 | align-items: center; 26 | padding: 0 0 0.5rem; 27 | margin-bottom: 1rem; 28 | width: 6rem; 29 | position: relative; 30 | bottom: 0; 31 | transition: 32 | color 0.2s ease, 33 | bottom 0.5s ease; 34 | } 35 | 36 | .button .button-label::after { 37 | content: ''; 38 | position: absolute; 39 | bottom: 0; 40 | width: 0%; 41 | height: 0.6rem; 42 | z-index: 200; 43 | background: rgba(255 0 0 / 50%); 44 | display: block; 45 | transition: all 0.1s ease; 46 | opacity: 0; 47 | } 48 | 49 | .button:not(.is-selected):hover .button-label::after { 50 | opacity: 1; 51 | width: 90%; 52 | } 53 | 54 | .button svg { 55 | height: 2.5rem; 56 | padding: 1.3rem 0 0.6rem; 57 | width: auto; 58 | } 59 | 60 | .button-label { 61 | text-align: center; 62 | font-size: 1rem; 63 | color: var(--color-text); 64 | font-weight: var(--font-weight-standard); 65 | position: relative; 66 | } 67 | 68 | .is-selected { 69 | color: red; 70 | bottom: 0.25rem; 71 | } 72 | 73 | .indicator { 74 | position: relative; 75 | } 76 | 77 | .indicator::after { 78 | width: 6rem; 79 | height: 0.5rem; 80 | background: rgba(255 0 0 / 50%); 81 | bottom: 1rem; 82 | content: ''; 83 | display: block; 84 | position: absolute; 85 | transition: all 0.5s ease; 86 | } 87 | 88 | /** 89 | * Even space for n tabs, 90 | * viewport divided by n+1 slices 91 | * minus tab width applicable to slice 92 | * plus close button padding applicable to slice 93 | * 94 | * When :has has Firefox support: 95 | * .indicator:has(.button:nth-child(1).is-selected)::after 96 | */ 97 | .indicator.button-1-selected::after { 98 | left: calc((100vw * 0.2) - (24rem * 0.2) + (5rem - (5rem * 0.2))); 99 | } 100 | 101 | .indicator.button-2-selected::after { 102 | left: calc((100vw * 0.4) - (24rem * 0.15) + (5rem - (5rem * 0.4))); 103 | } 104 | 105 | .indicator.button-3-selected::after { 106 | left: calc((100vw * 0.6) - (24rem * 0.1) + (5rem - (5rem * 0.6))); 107 | } 108 | 109 | .indicator.button-4-selected::after { 110 | left: calc((100vw * 0.8) - (24rem * 0.05) + (5rem - (5rem * 0.8))); 111 | } 112 | -------------------------------------------------------------------------------- /src/components/TabBar/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "bar": string; 3 | readonly "is-visible": string; 4 | readonly "button": string; 5 | readonly "button-label": string; 6 | readonly "is-selected": string; 7 | readonly "indicator": string; 8 | readonly "button-1-selected": string; 9 | readonly "button-2-selected": string; 10 | readonly "button-3-selected": string; 11 | readonly "button-4-selected": string; 12 | }; 13 | export = styles; 14 | 15 | -------------------------------------------------------------------------------- /src/components/ThemingPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from 'app/hooks'; 2 | import * as styles from './style.module.css'; 3 | import { useCallback, useMemo } from 'react'; 4 | import { HexColorPicker, HexColorInput } from 'react-colorful'; 5 | import { 6 | resetUntaggedItemBGColor, 7 | selectedUntaggedItemBGColor, 8 | setUntaggedItemBGColor, 9 | } from 'modules/ui/uiSlice'; 10 | import Button from 'components/Button'; 11 | 12 | function pickTextColorBasedOnBgColor(bgColor: string) { 13 | const color = bgColor.substring(1, 7); 14 | const comps = (color.match(/.{1,2}/g) as Array).map((comp) => { 15 | const color = parseInt(comp, 16) / 255; 16 | if (color <= 0.03928) { 17 | return color / 12.92; 18 | } 19 | return Math.pow((color + 0.055) / 1.055, 2.4); 20 | }); 21 | const L = 0.2126 * comps[0] + 0.7152 * comps[1] + 0.0722 * comps[2]; 22 | return L > 0.179 ? '#000000' : '#FFFFFF'; 23 | } 24 | 25 | type ColorPickerProps = { 26 | color: string; 27 | onChange: (color: string) => void; 28 | onReset: () => void; 29 | ariaLabel: string; 30 | }; 31 | 32 | const ColorPicker: React.FC = ({ 33 | color, 34 | onChange, 35 | onReset, 36 | ariaLabel, 37 | }) => { 38 | return ( 39 |
40 | 41 | 50 |
51 | ); 52 | }; 53 | 54 | const ThemingPanel: React.FC = () => { 55 | const untaggedItemBG = useAppSelector(selectedUntaggedItemBGColor); 56 | const dispatch = useAppDispatch(); 57 | 58 | const handleUntaggedUIItemBGColorChange = useCallback( 59 | (color: string) => { 60 | dispatch(setUntaggedItemBGColor(color)); 61 | }, 62 | [dispatch] 63 | ); 64 | 65 | const handleUntaggedUIItemReset = useCallback(() => { 66 | dispatch(resetUntaggedItemBGColor()); 67 | }, [dispatch]); 68 | 69 | const untaggedItemBGInputStyle = useMemo(() => { 70 | return { color: pickTextColorBasedOnBgColor(untaggedItemBG) }; 71 | }, [untaggedItemBG]); 72 | 73 | return ( 74 |
75 | 79 |
80 | 88 | 94 |
95 |
96 | ); 97 | }; 98 | 99 | export default ThemingPanel; 100 | -------------------------------------------------------------------------------- /src/components/ThemingPanel/style.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable selector-class-pattern */ 2 | .panel { 3 | width: min(40vw, 40rem); 4 | display: grid; 5 | align-items: center; 6 | grid-template: 1fr / 1fr; 7 | grid-gap: 0.2rem; 8 | } 9 | 10 | @media (width >= 60rem) { 11 | .panel { 12 | grid-template: 1fr / 1fr 1fr; 13 | grid-gap: 2rem; 14 | } 15 | } 16 | 17 | .label { 18 | font-size: 0.875rem; 19 | font-weight: var(--font-weight-bold); 20 | letter-spacing: 0.29em; 21 | word-spacing: 0.29em; 22 | text-transform: uppercase; 23 | text-align: left; 24 | } 25 | 26 | @media (width >= 60rem) { 27 | .label { 28 | text-align: right; 29 | } 30 | } 31 | 32 | .input { 33 | background: var(--color-untagged-item-bg); 34 | font-size: 1.7rem; 35 | font-family: var(--font-mono); 36 | font-weight: var(--font-weight-light); 37 | width: 100%; 38 | border: 2px solid var(--color-item-shadow); 39 | box-sizing: border-box; 40 | text-align: center; 41 | box-shadow: 4px 4px 0 0 var(--color-options-form-input-box-shadow) inset; 42 | height: 3rem; 43 | padding: 0.5rem 2.2rem 0.5rem 0.5rem; 44 | transition: all 0.1s ease-in; 45 | position: relative; 46 | } 47 | 48 | .input:focus, 49 | .input:active, 50 | .picker:active ~ .input, 51 | .picker:focus ~ .input, 52 | .picker:hover ~ .input { 53 | outline: none; 54 | box-shadow: 4px 4px 0 0 var(--color-item-shadow); 55 | } 56 | 57 | .input-container { 58 | width: 100%; 59 | position: relative; 60 | transition: all 0.1s ease-in; 61 | } 62 | 63 | .input-container .input:focus, 64 | .input-container .input:active, 65 | .input-container .picker:active ~ .input, 66 | .input-container .picker:focus ~ .input, 67 | .input-container .picker:hover ~ .input { 68 | transform: translate(-0.1rem, -0.1rem); 69 | } 70 | 71 | .input + .picker { 72 | opacity: 0; 73 | } 74 | 75 | .input:focus + .picker, 76 | .picker:active, 77 | .picker:focus, 78 | .picker:hover { 79 | opacity: 1; 80 | } 81 | 82 | .picker::before { 83 | content: ''; 84 | position: absolute; 85 | height: 0; 86 | width: 0; 87 | border-top: 1rem solid transparent; 88 | border-bottom: 1rem solid black; 89 | border-right: 1rem solid transparent; 90 | border-left: 1rem solid transparent; 91 | top: -2rem; 92 | left: calc(50% - 1rem); 93 | border-style: solid; 94 | } 95 | 96 | .picker::after { 97 | content: ''; 98 | position: absolute; 99 | height: 0; 100 | width: 0; 101 | border-top: 0.9rem solid transparent; 102 | border-bottom: 0.9rem solid white; 103 | border-right: 0.9rem solid transparent; 104 | border-left: 0.9rem solid transparent; 105 | top: -1.75rem; 106 | left: calc(50% - 0.9rem); 107 | border-style: solid; 108 | } 109 | 110 | .picker { 111 | width: 100%; 112 | height: auto; 113 | position: absolute; 114 | border: 2px solid var(--color-item-shadow); 115 | box-shadow: 0.4rem 0.4rem 0 var(--color-item-shadow); 116 | display: grid; 117 | grid-template: 3fr 1fr / 1fr; 118 | background: white; 119 | box-sizing: border-box; 120 | right: 0; 121 | top: 3.9rem; 122 | transition: all 0.2s ease; 123 | } 124 | 125 | .picker :global(.react-colorful) { 126 | width: 100%; 127 | height: 100%; 128 | box-sizing: border-box; 129 | padding: 2px; 130 | border-radius: 0; 131 | background: white; 132 | } 133 | 134 | .picker :global(.react-colorful__saturation), 135 | .picker :global(.react-colorful__hue) { 136 | border-radius: 0; 137 | border: none; 138 | } 139 | 140 | .picker :global(.react-colorful__saturation-pointer), 141 | .picker :global(.react-colorful__hue-pointer), 142 | .picker :global(.react-colorful__alpha-pointer) { 143 | border-radius: 0; 144 | border: 2px solid var(--color-item-shadow); 145 | box-shadow: 146 | 0 0 0 2px white, 147 | 0 0 0 4px var(--color-item-shadow); 148 | } 149 | 150 | .picker :global(.react-colorful__saturation-pointer) { 151 | width: 1.25rem; 152 | height: 1.25rem; 153 | border-radius: 0; 154 | } 155 | 156 | .picker :global(.react-colorful__hue-pointer), 157 | .picker :global(.react-colorful__alpha-pointer) { 158 | width: 1rem; 159 | } 160 | 161 | .reset { 162 | margin: 1rem; 163 | } 164 | 165 | .reset:hover, 166 | .reset:active { 167 | margin: 0.98rem; 168 | } 169 | -------------------------------------------------------------------------------- /src/components/ThemingPanel/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "panel": string; 3 | readonly "label": string; 4 | readonly "input": string; 5 | readonly "picker": string; 6 | readonly "input-container": string; 7 | readonly "reset": string; 8 | }; 9 | export = styles; 10 | 11 | -------------------------------------------------------------------------------- /src/components/Time/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { TIME_WARNING_THRESHOLD } from '../../lib/constants'; 3 | import { useAppSelector } from 'app/hooks'; 4 | import { 5 | selectedIsClockedIn, 6 | selectedItemClockStartTime, 7 | selectedItemEffortMinutes, 8 | selectedItemPreviouslyClockedMinutes, 9 | } from 'modules/emacs/emacsSlice'; 10 | import { selectedIsInSync } from 'modules/ws/wsSlice'; 11 | import { useCallback, useEffect, useState } from 'react'; 12 | 13 | const ClockedTime: React.FC = () => { 14 | const isInSync = useAppSelector(selectedIsInSync); 15 | const isClockedIn = useAppSelector(selectedIsClockedIn); 16 | 17 | const itemPreviouslyClockedMinutes = useAppSelector( 18 | selectedItemPreviouslyClockedMinutes 19 | ); 20 | const itemEffortMinutes = useAppSelector(selectedItemEffortMinutes); 21 | const itemClockStartTime = useAppSelector(selectedItemClockStartTime); 22 | 23 | const [minutesClockedIn, setMinutesClockedIn] = useState( 24 | itemPreviouslyClockedMinutes 25 | ); 26 | const minutesToTimeString = useCallback((minutes: number): string => { 27 | const hours = Math.floor(minutes / 60); 28 | const remainingMinutes = minutes % 60; 29 | 30 | const formattedHours = hours.toString(); 31 | const formattedMinutes = remainingMinutes.toString().padStart(2, '0'); 32 | 33 | return `${formattedHours}:${formattedMinutes}`; 34 | }, []); 35 | 36 | const calculateMinutesClockedIn = useCallback(() => { 37 | const now = new Date().getTime(); 38 | const start = new Date(itemClockStartTime as number).getTime(); 39 | const diff = Math.floor((now - start) / 1000 / 60); 40 | const total = diff + itemPreviouslyClockedMinutes; 41 | setMinutesClockedIn(total); 42 | }, [itemClockStartTime, itemPreviouslyClockedMinutes]); 43 | 44 | useEffect(() => { 45 | calculateMinutesClockedIn(); 46 | const interval = setInterval(calculateMinutesClockedIn, 5000); 47 | return () => clearInterval(interval); 48 | }, [calculateMinutesClockedIn]); 49 | 50 | const determineOvertimeStrokeColor = useCallback(() => { 51 | if ( 52 | itemEffortMinutes && 53 | itemClockStartTime && 54 | minutesClockedIn > itemEffortMinutes * TIME_WARNING_THRESHOLD 55 | ) { 56 | if (minutesClockedIn > itemEffortMinutes) { 57 | return 'rgb(255, 0, 0)'; 58 | } else { 59 | const numberOfWarningMinutes = 60 | itemEffortMinutes - 61 | itemEffortMinutes * TIME_WARNING_THRESHOLD; 62 | const minutesLeft = itemEffortMinutes - minutesClockedIn; 63 | const percentageLeft = 64 | 1 - 65 | (numberOfWarningMinutes - minutesLeft) / 66 | numberOfWarningMinutes; 67 | const color = Math.floor(255 * percentageLeft); 68 | return `rgb(255, ${color}, ${color})`; 69 | } 70 | } 71 | return undefined; 72 | }, [itemEffortMinutes, itemClockStartTime, minutesClockedIn]); 73 | 74 | if (!isInSync || !isClockedIn) { 75 | return null; 76 | } 77 | 78 | return ( 79 |
84 | {minutesToTimeString(minutesClockedIn)} 85 | {itemEffortMinutes && ( 86 | <> 87 | {' / '} 88 | {minutesToTimeString(itemEffortMinutes)} 89 | 90 | )} 91 |
92 | ); 93 | }; 94 | 95 | export default ClockedTime; 96 | -------------------------------------------------------------------------------- /src/components/Time/style.module.css: -------------------------------------------------------------------------------- 1 | .clock { 2 | position: fixed; 3 | bottom: 0; 4 | color: transparent; 5 | opacity: 0.3; 6 | font-weight: var(--font-weight-bold); 7 | font-style: italic; 8 | font-size: 14vw; 9 | -webkit-text-stroke-color: white; 10 | -webkit-text-stroke-width: 0.12vw; 11 | line-height: 0.8; /* Using tabular numbers */ 12 | } 13 | 14 | @media (width >= 40rem) { 15 | .clock { 16 | -webkit-text-stroke-width: 0.08vw; 17 | font-size: 7.5vw; 18 | right: 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Time/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "clock": string; 3 | }; 4 | export = styles; 5 | 6 | -------------------------------------------------------------------------------- /src/components/WidgetArea/index.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './style.module.css'; 2 | import { 3 | Area, 4 | selectedConnectionStatusArea, 5 | selectedOrgItemArea, 6 | } from 'modules/layout/layoutSlice'; 7 | import classNames from 'classnames'; 8 | import { useAppSelector } from 'app/hooks'; 9 | import ConnectionStatusIndicator from 'components/ConnectionStatusIndicator'; 10 | import OrgItem from 'components/OrgItem'; 11 | 12 | type WidgetAreaProps = { 13 | loc: Area; 14 | }; 15 | 16 | const WidgetArea: React.FC = ({ loc }) => { 17 | const connectionStatusArea = useAppSelector(selectedConnectionStatusArea); 18 | const orgItemArea = useAppSelector(selectedOrgItemArea); 19 | 20 | return ( 21 |
28 | {loc === orgItemArea && } 29 | {loc === connectionStatusArea && } 30 |
31 | ); 32 | }; 33 | 34 | export default WidgetArea; 35 | -------------------------------------------------------------------------------- /src/components/WidgetArea/style.module.css: -------------------------------------------------------------------------------- 1 | .top, 2 | .mid, 3 | .bottom { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | } 8 | 9 | .top { 10 | justify-content: flex-start; 11 | } 12 | 13 | .mid { 14 | justify-content: center; 15 | } 16 | 17 | .bottom { 18 | justify-content: flex-end; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/WidgetArea/style.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "top": string; 3 | readonly "mid": string; 4 | readonly "bottom": string; 5 | }; 6 | export = styles; 7 | 8 | -------------------------------------------------------------------------------- /src/lib/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | type IconProps = { 4 | icon: React.ReactNode; 5 | }; 6 | 7 | const Icon: React.FC = ({ icon }) => { 8 | return <>{icon}; 9 | }; 10 | 11 | const MemoizedIcon = memo(Icon, () => true); 12 | 13 | export default MemoizedIcon; 14 | -------------------------------------------------------------------------------- /src/lib/Persistor.ts: -------------------------------------------------------------------------------- 1 | import type { Persistor } from '@plasmohq/redux-persist/lib/types'; 2 | 3 | /** 4 | * Persistor class (persisted store) to be used as a singleton. Needed to 5 | * allow middleware to reference persistor while also allowing the store to 6 | * be created before the persistor. 7 | */ 8 | class PersistorClass { 9 | static #instance: PersistorClass; 10 | #persistedStore: Persistor | undefined = undefined; 11 | #flushing: boolean = false; 12 | 13 | private constructor() { 14 | if (PersistorClass.#instance) { 15 | throw new Error('Use PersistorClass.Instance() instead of new.'); 16 | } 17 | PersistorClass.#instance = this; 18 | } 19 | 20 | public static getInstance() { 21 | return this.#instance || (this.#instance = new this()); 22 | } 23 | 24 | public getStore() { 25 | if (this.#persistedStore) { 26 | return this.#persistedStore; 27 | } else { 28 | throw new Error('Store not persisted yet.'); 29 | } 30 | } 31 | 32 | public setStore(p: Persistor) { 33 | this.#persistedStore = p; 34 | } 35 | 36 | public async flush() { 37 | this.#flushing = true; 38 | await this.#persistedStore?.flush(); 39 | // Wait until storage is written, .flush doesn't promise storage write 40 | chrome.storage.local.get(() => { 41 | this.#flushing = false; 42 | // Persisting will rehydrate, no need to manually call/queue it 43 | this.#persistedStore?.persist(); 44 | }); 45 | } 46 | 47 | public async resync() { 48 | // Will be rehydrated when flushing is done 49 | if (!this.#flushing) { 50 | await this.#persistedStore?.resync(); 51 | } 52 | } 53 | 54 | public get isFlushing() { 55 | return this.#flushing; 56 | } 57 | } 58 | 59 | export default PersistorClass.getInstance(); 60 | -------------------------------------------------------------------------------- /src/lib/Port.ts: -------------------------------------------------------------------------------- 1 | import { log } from './logging'; 2 | import { LogLoc } from './types'; 3 | 4 | class Port { 5 | static #instance: Port; 6 | #port: chrome.runtime.Port | null; 7 | #name: string; 8 | 9 | private constructor(name: string) { 10 | if (Port.#instance) { 11 | throw new Error('[NewTab] Use Port.Instance() instead of new.'); 12 | } 13 | this.#name = name; 14 | this.#port = null; 15 | this.#createPort(); 16 | Port.#instance = this; 17 | } 18 | 19 | public static getInstance(name: string) { 20 | return this.#instance || (this.#instance = new this(name)); 21 | } 22 | 23 | #createPort() { 24 | const handlePortDisconnect = () => { 25 | log(LogLoc.NEWTAB, 'Port disconnected, reconnecting...'); 26 | this.#createPort(); 27 | }; 28 | 29 | this.#port = chrome.runtime.connect({ name: this.#name }); 30 | if (!this.#port.onDisconnect.hasListener(handlePortDisconnect)) { 31 | this.#port.onDisconnect.addListener(handlePortDisconnect); 32 | } 33 | } 34 | 35 | public get port() { 36 | return this.#port as chrome.runtime.Port; 37 | } 38 | } 39 | 40 | export default Port.getInstance('ws'); 41 | -------------------------------------------------------------------------------- /src/lib/Socket.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from 'partysocket'; 2 | import { RECONNECTION_ATTEMPT_GROWTH_FACTOR } from './constants'; 3 | 4 | const WebSocketOptions = { 5 | reconnectionDelayGrowFactor: RECONNECTION_ATTEMPT_GROWTH_FACTOR, 6 | debug: false, 7 | }; 8 | class Socket { 9 | static #instance: Socket; 10 | #socket: WebSocket | null; 11 | 12 | private constructor() { 13 | if (Socket.#instance) { 14 | throw new Error('[NewTab] Use Socket.Instance() instead of new.'); 15 | } 16 | Socket.#instance = this; 17 | this.#socket = null; 18 | } 19 | 20 | public static getInstance() { 21 | return this.#instance || (this.#instance = new this()); 22 | } 23 | 24 | public connect(url: string) { 25 | if (!this.#socket) { 26 | this.#socket = new WebSocket(url, [], WebSocketOptions); 27 | } 28 | } 29 | 30 | public disconnect() { 31 | if (this.#socket) { 32 | this.#socket.close(); 33 | this.#socket = null; 34 | } 35 | } 36 | 37 | public sendJSON(message: Record) { 38 | if (this.#socket) { 39 | this.#socket.send(JSON.stringify(message)); 40 | } 41 | } 42 | 43 | public on( 44 | eventName: T, 45 | listener: Parameters>[1] 46 | ) { 47 | if (this.#socket) { 48 | this.#socket.addEventListener(eventName, listener); 49 | } 50 | } 51 | 52 | public get exists() { 53 | return this.#socket !== null; 54 | } 55 | } 56 | 57 | export default Socket.getInstance(); 58 | 59 | // TODO: implement heartbeat, see https://github.com/pladaria/reconnecting-websocket/issues/170 60 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { LogLoc } from './types'; 2 | 3 | /** Default WebSocket port as also defined on the Elisp side */ 4 | export const DEFAULT_WEBSOCKET_PORT = 35942; 5 | 6 | /** For testing, how to wait for response from dummy WebSocket server */ 7 | export const MAXIMUM_TIME_TO_WAIT_FOR_RESPONSE = 20000; 8 | 9 | /** How long to increase wait time between WebSocket reconnection attempts */ 10 | export const RECONNECTION_ATTEMPT_GROWTH_FACTOR = 1.5; 11 | 12 | /** Percentage of expected effort for when to start warning time spent is getting near */ 13 | export const TIME_WARNING_THRESHOLD = 0.8; 14 | 15 | /** Enable BGSW related logging and direction for logs */ 16 | export const ENABLE_BGSW_LOGGING: LogLoc = LogLoc.NONE; 17 | 18 | /** Enable Redux related logging (ex: action and state changes) */ 19 | export const ENABLE_REDUX_LOGGING = false; 20 | 21 | /** Enable Storage related logging (ex: updating storage keys) */ 22 | export const ENABLE_STORAGE_LOGGING = false; 23 | -------------------------------------------------------------------------------- /src/lib/logging.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { ENABLE_BGSW_LOGGING } from './constants'; 4 | import { LogLoc, LogMsgDir } from './types'; 5 | 6 | export const log = (loc: LogLoc, ...args: Parameters) => { 7 | if (ENABLE_BGSW_LOGGING === loc) { 8 | console.log(`[${loc}]`, ...args); 9 | } 10 | }; 11 | 12 | export const logMsg = ( 13 | loc: LogLoc, 14 | dir: LogMsgDir, 15 | ...args: Parameters 16 | ) => { 17 | if (ENABLE_BGSW_LOGGING === loc) { 18 | log(loc, dir, ...args); 19 | } 20 | }; 21 | 22 | export const logMsgErr = ( 23 | loc: LogLoc, 24 | dir: LogMsgDir, 25 | ...args: Parameters 26 | ) => { 27 | if (ENABLE_BGSW_LOGGING === loc) { 28 | console.error(`[${loc}]`, dir, ...args); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/messages.ts: -------------------------------------------------------------------------------- 1 | import { logMsg, logMsgErr } from './logging'; 2 | import { 3 | LogLoc, 4 | LogMsgDir, 5 | MsgDirection, 6 | type MsgToTab, 7 | MsgToTabType, 8 | MsgToBGSWType, 9 | getMsgToBGSWType, 10 | getMsgToTabType, 11 | MsgToBGSW, 12 | WSStateMsg, 13 | MsgToTabData, 14 | } from './types'; 15 | 16 | export type SendResponseType = (message: MsgToBGSW | MsgToTab) => unknown; 17 | 18 | /** 19 | * General messaging functions 20 | */ 21 | 22 | export const sendMsgToBGSWPort = ( 23 | type: MsgToBGSWType, 24 | port: chrome.runtime.Port 25 | ) => { 26 | logMsg( 27 | LogLoc.NEWTAB, 28 | LogMsgDir.SEND, 29 | 'Sending message to BGSW port', 30 | getMsgToBGSWType(type) 31 | ); 32 | port.postMessage({ 33 | type, 34 | direction: MsgDirection.TO_BGSW, 35 | }); 36 | }; 37 | 38 | export const sendMsgToBGSWAsResponse = ( 39 | type: MsgToBGSWType, 40 | sendResponse: SendResponseType 41 | ) => { 42 | logMsg( 43 | LogLoc.NEWTAB, 44 | LogMsgDir.SEND, 45 | 'Sending response to BGSW msg', 46 | getMsgToBGSWType(type) 47 | ); 48 | sendResponse({ 49 | type, 50 | direction: MsgDirection.TO_BGSW, 51 | }); 52 | }; 53 | 54 | export const sendMsgToTab = ( 55 | type: MsgToTabType, 56 | tabId: number, 57 | data?: MsgToTabData 58 | ) => { 59 | logMsg( 60 | LogLoc.NEWTAB, 61 | LogMsgDir.SEND, 62 | 'Sending request to master tab', 63 | tabId, 64 | getMsgToTabType(type), 65 | data ? data : '' 66 | ); 67 | chrome.tabs 68 | .sendMessage(tabId, { 69 | direction: MsgDirection.TO_NEWTAB, 70 | type, 71 | data, 72 | }) 73 | .catch((err) => { 74 | logMsgErr( 75 | LogLoc.NEWTAB, 76 | LogMsgDir.SEND, 77 | 'Error sending message to master tab:', 78 | err 79 | ); 80 | }); 81 | }; 82 | 83 | export const sendMsgToAllTabs = (type: MsgToTabType, data?: WSStateMsg) => { 84 | logMsg( 85 | LogLoc.NEWTAB, 86 | LogMsgDir.SEND, 87 | 'Sending update to all tabs', 88 | getMsgToTabType(type), 89 | data ? data : '' 90 | ); 91 | chrome.runtime 92 | .sendMessage({ 93 | direction: MsgDirection.TO_NEWTAB, 94 | type, 95 | data, 96 | }) 97 | .catch((err) => { 98 | logMsgErr( 99 | LogLoc.NEWTAB, 100 | LogMsgDir.SEND, 101 | 'Error sending message to all tabs:', 102 | err 103 | ); 104 | }); 105 | }; 106 | 107 | /** 108 | * BGSW related messaging functions 109 | */ 110 | 111 | export const handleConfirmingRoleAsMaster = ( 112 | sendResponse: SendResponseType, 113 | amMasterRole: boolean 114 | ) => { 115 | if (amMasterRole) { 116 | sendMsgToBGSWAsResponse( 117 | MsgToBGSWType.IDENTIFY_ROLE_MASTER, 118 | sendResponse 119 | ); 120 | } else { 121 | sendMsgToBGSWAsResponse( 122 | MsgToBGSWType.IDENTIFY_ROLE_CLIENT, 123 | sendResponse 124 | ); 125 | } 126 | }; 127 | 128 | export const handleConfirmingAlive = (sendResponse: SendResponseType) => { 129 | sendMsgToBGSWAsResponse(MsgToBGSWType.CONFIRMING_ALIVE, sendResponse); 130 | }; 131 | 132 | export const getMasterWSTabId = async () => { 133 | const masterWSObject = await chrome.storage.local.get('masterWSTabId'); 134 | const { masterWSTabId } = masterWSObject; 135 | const masterWSTabAsNumber = 136 | masterWSTabId && typeof masterWSTabId === 'string' 137 | ? parseInt(masterWSTabId, 10) 138 | : null; 139 | 140 | return masterWSTabAsNumber; 141 | }; 142 | 143 | /** 144 | * WS (inter-tab) related messaging functions 145 | */ 146 | 147 | export const sendUpdateInWSState = (data: WSStateMsg) => { 148 | sendMsgToAllTabs(MsgToTabType.SET_WS_STATE, data); 149 | }; 150 | 151 | export const sendToMasterTab = (type: MsgToTabType, data?: MsgToTabData) => { 152 | void getMasterWSTabId().then((masterWSTabNum) => { 153 | if (masterWSTabNum) { 154 | sendMsgToTab(type, masterWSTabNum, data); 155 | } 156 | }); 157 | }; 158 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Message logging 3 | */ 4 | export enum LogLoc { 5 | BGSW = 'BGSW', 6 | NEWTAB = 'NewTab', 7 | NONE = 'NONE', 8 | } 9 | 10 | export enum LogMsgDir { 11 | SEND = '=>', 12 | RECV = '<=', 13 | } 14 | 15 | /** 16 | * BGSW messages 17 | */ 18 | 19 | export enum MsgToBGSWType { 20 | QUERY_WS_ROLE = 1, 21 | IDENTIFY_ROLE_MASTER = 2, 22 | IDENTIFY_ROLE_CLIENT = 3, 23 | CONFIRMING_ALIVE = 4, 24 | } 25 | 26 | export const getMsgToBGSWType = (type: MsgToBGSWType) => { 27 | return MsgToBGSWType[type]; 28 | }; 29 | 30 | export enum MsgToTabType { 31 | CONFIRM_YOUR_ROLE_IS_MASTER = 1, 32 | SET_ROLE_MASTER = 2, 33 | SET_ROLE_CLIENT = 3, 34 | QUERY_ALIVE = 4, 35 | PASS_TO_EMACS = 11, 36 | QUERY_WS_STATE = 12, 37 | SET_WS_STATE = 13, 38 | SET_WS_PORT = 14, 39 | } 40 | 41 | export const getMsgToTabType = (type: MsgToTabType) => { 42 | return MsgToTabType[type]; 43 | }; 44 | 45 | export enum MsgDirection { 46 | TO_BGSW = 1, 47 | TO_NEWTAB = 2, 48 | } 49 | 50 | export type MsgToBGSW = { 51 | direction: MsgDirection.TO_BGSW; 52 | type: MsgToBGSWType; 53 | }; 54 | 55 | /** 56 | * Inter-tab messages 57 | */ 58 | 59 | export type MsgToTabData = EmacsSendMsg | WSStateMsg | WSPortMsg; 60 | 61 | export type MsgToTab = { 62 | direction: MsgDirection.TO_NEWTAB; 63 | type: MsgToTabType; 64 | data?: MsgToTabData; 65 | }; 66 | 67 | export enum WSReadyState { 68 | UNINSTANTIATED = -1, 69 | CONNECTING = 0, 70 | OPEN = 1, 71 | CLOSING = 2, 72 | CLOSED = 3, 73 | } 74 | 75 | export interface ResponseData { 76 | id: number; 77 | type: EmacsMsgTypes | ''; 78 | } 79 | 80 | export type WSStateMsg = { 81 | readyState?: WSReadyState; 82 | responsesWaitingFor?: Array; 83 | }; 84 | 85 | export type WSPortMsg = { 86 | port?: number; 87 | }; 88 | 89 | /** 90 | * Messages from Emacs 91 | */ 92 | 93 | export type AllTagsRecv = string | Array; 94 | 95 | export type EmacsItemType = 'ITEM'; 96 | export type EmacsItemMsg = { 97 | type: EmacsItemType; 98 | data: { 99 | ITEM: string; 100 | ALLTAGS?: AllTagsRecv; 101 | CATEGORY?: string; 102 | LAST_REPEAT?: string; 103 | EFFORT?: string; 104 | TIMESTAMP_IA?: string; 105 | SCHEDULED?: string; 106 | DEADLINE?: string; 107 | FILE?: string; 108 | PRIORITY?: string; 109 | TODO?: string; 110 | EFFORT_MINUTES?: number; 111 | PREVIOUSLY_CLOCKED_MINUTES?: number; 112 | CURRENT_CLOCK_START_TIMESTAMP?: number; 113 | }; 114 | resid?: number; // Clocked in won't have one 115 | }; 116 | 117 | export type EmacsTagsType = 'TAGS'; 118 | export type EmacsTagsMsg = { 119 | type: EmacsTagsType; 120 | data: { 121 | [key: string]: string | null; 122 | } | null; 123 | }; 124 | 125 | // Async task started based on Emacs hook, start loading bar 126 | export type EmacsFindingType = 'FINDING'; 127 | export type EmacsFindingMsg = { 128 | type: EmacsFindingType; 129 | resid: number; 130 | }; 131 | 132 | export type EmacsMsgTypes = EmacsItemType | EmacsTagsType | EmacsFindingType; 133 | 134 | export type EmacsRecvMsg = EmacsItemMsg | EmacsTagsMsg | EmacsFindingMsg | null; 135 | 136 | export const isItemMsg = (msg: EmacsRecvMsg): msg is EmacsItemMsg => { 137 | return msg?.type === 'ITEM'; 138 | }; 139 | 140 | export const isFindingMsg = (msg: EmacsRecvMsg): msg is EmacsFindingMsg => { 141 | return msg?.type === 'FINDING'; 142 | }; 143 | 144 | /** 145 | * Messages to Emacs 146 | */ 147 | export type EmacsGetItemCommand = 'getItem'; 148 | export type EmacsCommands = EmacsGetItemCommand; 149 | 150 | export const commandToTypeMapping: Record = { 151 | getItem: 'ITEM', 152 | }; 153 | export const getTypeFromCommand = (command: EmacsCommands): EmacsMsgTypes => { 154 | return commandToTypeMapping[command]; 155 | }; 156 | 157 | export type EmacsSendMsg = { 158 | command: EmacsCommands; 159 | data: string; 160 | }; 161 | 162 | export type EmacsSendMsgWithResid = EmacsSendMsg & { 163 | resid: number; 164 | }; 165 | 166 | /** Utility types */ 167 | export type Entries = { 168 | [K in keyof T]: [K, T[K]]; 169 | }[keyof T][]; 170 | -------------------------------------------------------------------------------- /src/lib/wdyr.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import whyDidYouRender from '@welldone-software/why-did-you-render'; 3 | 4 | if (process.env.NODE_ENV === 'development') { 5 | whyDidYouRender(React, { 6 | trackAllPureComponents: true, 7 | trackHooks: true, 8 | include: [/.*/], 9 | exclude: [ 10 | /^Transition$/, 11 | /* DnDKit */ 12 | /^DraggableWidget$/, 13 | /^DndContext$/, 14 | /^LiveRegion$/, 15 | /^Accessibility$/, 16 | /^AnimationManager$/, 17 | /^RestoreFocus$/, 18 | /^NullifiedContextProvider$/, 19 | /^HiddenText$/, 20 | /^Unknown$/, 21 | /** React-colorful */ 22 | /^U$/, 23 | /^p$/, 24 | /^ke$/, 25 | ], 26 | collapseGroups: true, 27 | diffNameColor: 'darkturquoise', 28 | titleColor: 'lavender', 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/emacs/emacsSlice.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; 3 | import { listenerMiddleware } from 'app/middleware'; 4 | import { resetData } from 'app/actions'; 5 | import { RootState } from 'app/store'; 6 | import { EmacsSendMsg, EmacsRecvMsg, AllTagsRecv } from 'lib/types'; 7 | import type { PersistedState } from '@plasmohq/redux-persist/lib/types'; 8 | 9 | type MatchQuery = string | undefined; 10 | type TagFaces = Array<{ tag: string; color: string | null }>; 11 | type ItemText = string | null; 12 | type ItemTags = Array; 13 | type ItemClockStartTime = number | null; 14 | type ItemPreviouslyClockedMinutes = number; 15 | type ItemEffortMinutes = number | null; 16 | 17 | export interface EmacsState { 18 | matchQuery: MatchQuery; 19 | tagFaces: TagFaces; 20 | itemText: ItemText; 21 | itemTags: ItemTags; 22 | itemClockStartTime: ItemClockStartTime; 23 | itemPreviouslyClockedMinutes: ItemPreviouslyClockedMinutes; 24 | itemEffortMinutes: ItemEffortMinutes; 25 | } 26 | 27 | export const name = 'emacs'; 28 | export const persistenceBlacklist: Array = []; 29 | export const persistenceVersion = 2; 30 | export const persistenceMigrations = { 31 | // tagFaces previously a flat object { tag: color } 32 | 2: (state: PersistedState) => { 33 | return { 34 | ...state, 35 | tagFaces: [], 36 | } as PersistedState; 37 | }, 38 | }; 39 | 40 | const initialState: EmacsState = { 41 | matchQuery: 'TODO="TODO"', 42 | tagFaces: [], 43 | itemText: null, 44 | itemTags: [], 45 | itemClockStartTime: null, 46 | itemPreviouslyClockedMinutes: 0, 47 | itemEffortMinutes: null, 48 | }; 49 | 50 | const extractTagsFromItemAllTags = (allTagsData?: AllTagsRecv): ItemTags => { 51 | let allTags: Array | string | undefined; 52 | if (Array.isArray(allTagsData)) { 53 | allTags = []; 54 | allTagsData 55 | .filter( 56 | (tag): tag is string => typeof tag === 'string' && tag !== '' 57 | ) 58 | .forEach((tag) => { 59 | const splitTags = tag.split(':').filter((tag) => tag !== ''); 60 | (allTags as Array).push(...splitTags); 61 | }); 62 | } else { 63 | allTags = allTagsData?.split(':').filter((tag) => tag !== ''); 64 | } 65 | const cleanedAllTags = allTags?.map((tag) => 66 | tag.replace(/^:(.*):$/i, '$1') 67 | ); 68 | return cleanedAllTags || []; 69 | }; 70 | 71 | const emacsSlice = createSlice({ 72 | name, 73 | initialState, 74 | extraReducers: (builder) => builder.addCase(resetData, () => initialState), 75 | reducers: { 76 | setMatchQueryTo: (state, action: PayloadAction) => { 77 | state.matchQuery = action.payload; 78 | }, 79 | setTagsDataTo: (state, action: PayloadAction) => { 80 | return { ...state, tagFaces: action.payload }; 81 | }, 82 | getItem: () => {}, 83 | _sendMsgToEmacs: (_state, _action: PayloadAction) => {}, 84 | _recvMsgFromEmacs: (state, action: PayloadAction) => { 85 | const { payload } = action; 86 | if (payload === null) return; 87 | switch (payload.type) { 88 | case 'ITEM': 89 | return { 90 | ...state, 91 | itemText: payload?.data?.ITEM || null, 92 | itemTags: extractTagsFromItemAllTags( 93 | payload?.data?.ALLTAGS 94 | ), 95 | itemClockStartTime: 96 | payload?.data?.CURRENT_CLOCK_START_TIMESTAMP || 97 | null, 98 | itemPreviouslyClockedMinutes: 99 | payload?.data?.PREVIOUSLY_CLOCKED_MINUTES || 0, 100 | itemEffortMinutes: 101 | payload?.data?.EFFORT_MINUTES || null, 102 | }; 103 | break; 104 | case 'TAGS': 105 | return { 106 | ...state, 107 | tagFaces: Object.entries(payload?.data || {}).map( 108 | ([tag, color]) => { 109 | return { tag, color }; 110 | } 111 | ), 112 | }; 113 | break; 114 | case 'FINDING': 115 | return state; 116 | break; 117 | default: 118 | console.error('[NewTab] Unknown message: ', payload); 119 | return state; 120 | break; 121 | } 122 | }, 123 | }, 124 | }); 125 | 126 | export const selectedMatchQuery = (state: RootState) => state.emacs.matchQuery; 127 | export const selectedTagsData = (state: RootState) => state.emacs.tagFaces; 128 | export const selectedItemText = (state: RootState) => state.emacs.itemText; 129 | export const selectedItemTags = (state: RootState) => state.emacs.itemTags; 130 | export const selectedTagColors = createSelector( 131 | [selectedItemTags, selectedTagsData], 132 | (itemTags, tagsData) => { 133 | const tagsDataTags = tagsData.map((tag) => tag.tag); 134 | const appliedTags = itemTags.filter((tag) => 135 | tagsDataTags.includes(tag) 136 | ); 137 | const colors = appliedTags.map((tag) => { 138 | return ( 139 | tagsData.find((tagObj) => tagObj.tag === tag)?.color || 140 | undefined 141 | ); 142 | }); 143 | return colors; 144 | } 145 | ); 146 | export const selectedItemClockStartTime = (state: RootState) => 147 | state.emacs.itemClockStartTime; 148 | export const selectedItemPreviouslyClockedMinutes = (state: RootState) => 149 | state.emacs.itemPreviouslyClockedMinutes; 150 | export const selectedItemEffortMinutes = (state: RootState) => 151 | state.emacs.itemEffortMinutes; 152 | export const selectedIsClockedIn = (state: RootState) => 153 | state.emacs.itemClockStartTime; 154 | 155 | export const { 156 | setMatchQueryTo, 157 | setTagsDataTo, 158 | getItem, 159 | _sendMsgToEmacs, 160 | _recvMsgFromEmacs, 161 | } = emacsSlice.actions; 162 | 163 | export default emacsSlice.reducer; 164 | 165 | /** 166 | * Get the current item from Emacs on request 167 | */ 168 | listenerMiddleware.startListening({ 169 | actionCreator: getItem, 170 | effect: (_action, listenerApi) => { 171 | const { dispatch } = listenerApi; 172 | const getState = listenerApi.getState.bind(this); 173 | const { 174 | emacs: { matchQuery }, 175 | } = getState(); 176 | const jsonMessage = { 177 | command: 'getItem', 178 | data: matchQuery, 179 | } as EmacsSendMsg; 180 | dispatch(_sendMsgToEmacs(jsonMessage)); 181 | }, 182 | }); 183 | 184 | /** 185 | * Ask for current item if the match query changes 186 | */ 187 | listenerMiddleware.startListening({ 188 | actionCreator: setMatchQueryTo, 189 | effect: (action, listenerApi) => { 190 | const { dispatch } = listenerApi; 191 | const { 192 | emacs: { matchQuery: prevMatchQuery }, 193 | } = listenerApi.getOriginalState(); 194 | const currMatchQuery = action.payload; 195 | if (prevMatchQuery !== currMatchQuery) { 196 | dispatch(getItem()); 197 | } 198 | }, 199 | }); 200 | -------------------------------------------------------------------------------- /src/modules/layout/layoutSlice.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; 2 | import { resetData } from 'app/actions'; 3 | import { RootState } from 'app/store'; 4 | import { Entries } from 'lib/types'; 5 | 6 | export enum Area { 7 | Top = 'top', 8 | Mid = 'mid', 9 | Bottom = 'bottom', 10 | None = 'none', 11 | } 12 | 13 | export type LayoutPos = { 14 | order: number; 15 | area: Area; 16 | }; 17 | 18 | export interface LayoutState { 19 | connectionStatus: LayoutPos; 20 | orgItem: LayoutPos; 21 | } 22 | 23 | export const name = 'layout'; 24 | export const persistenceBlacklist: Array = []; 25 | 26 | const initialState: LayoutState = { 27 | orgItem: { 28 | order: 0, 29 | area: Area.Mid, 30 | }, 31 | connectionStatus: { 32 | order: 0, 33 | area: Area.Bottom, 34 | }, 35 | }; 36 | 37 | export const layoutSlice = createSlice({ 38 | name, 39 | initialState, 40 | extraReducers: (builder) => builder.addCase(resetData, () => initialState), 41 | reducers: { 42 | setConnectionStatusAreaTo: (state, action: PayloadAction) => { 43 | state.connectionStatus.area = action.payload; 44 | }, 45 | setOrgItemAreaTo: (state, action: PayloadAction) => { 46 | state.orgItem.area = action.payload; 47 | }, 48 | setWidgetAreaTo( 49 | state, 50 | action: PayloadAction<{ widget: keyof LayoutState; area: Area }> 51 | ) { 52 | state[action.payload.widget].area = action.payload.area; 53 | }, 54 | resetLayout: () => initialState, 55 | }, 56 | }); 57 | 58 | export type LayoutSliceActions = keyof typeof layoutSlice.actions; 59 | 60 | export const { 61 | setConnectionStatusAreaTo, 62 | setOrgItemAreaTo, 63 | setWidgetAreaTo, 64 | resetLayout, 65 | } = layoutSlice.actions; 66 | 67 | export const selectedConnectionStatusArea = (state: RootState) => 68 | state.layout.connectionStatus.area; 69 | export const selectedOrgItemArea = (state: RootState) => 70 | state.layout.orgItem.area; 71 | export const selectedLayoutState = (state: RootState) => state.layout; 72 | export const selectedWidgetsInArea = createSelector( 73 | [selectedLayoutState, (_state: RootState, area: Area) => area], 74 | (layout: LayoutState, area: Area) => 75 | (Object.entries(layout) as Entries) 76 | .filter(([, value]) => value.area === area) 77 | .map(([widget]) => widget) 78 | ); 79 | 80 | export default layoutSlice.reducer; 81 | -------------------------------------------------------------------------------- /src/modules/role/roleSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, isAnyOf } from '@reduxjs/toolkit'; 2 | import { resetData } from 'app/actions'; 3 | import { listenerMiddleware } from 'app/middleware'; 4 | import { RootState } from 'app/store'; 5 | import Port from 'lib/Port'; 6 | import { 7 | sendMsgToBGSWPort, 8 | sendToMasterTab, 9 | sendUpdateInWSState, 10 | } from 'lib/messages'; 11 | import { MsgToBGSWType, MsgToTabType, WSReadyState } from 'lib/types'; 12 | import { getItem } from 'modules/emacs/emacsSlice'; 13 | import { 14 | _addToResponsesWaitingFor, 15 | _closeWS, 16 | _openWS, 17 | _removeFromResponsesWaitingFor, 18 | _resetWS, 19 | _setReadyStateTo, 20 | _setResponsesWaitingForTo, 21 | setWSPortTo, 22 | } from 'modules/ws/wsSlice'; 23 | 24 | /** 25 | * Role is defined as master or client and refers to the websocket connection. 26 | * The Master websocket is the one that actually talks to Emacs while the client 27 | * is a dummy connection that talks through the master. This is done to avoid 28 | * multiple websocket connections to Emacs -- multiple new tab pages may exist 29 | * but only one should actually be talking to Emacs. 30 | * 31 | * While a more sane strategy would be to handle the websocket connection in the 32 | * background service worker, this is not reliably possible as of writing in 33 | * Manifest v3. The BGSW may be terminated at any time and the connection would 34 | * be lost even though Emacs may still be sending messages over. 35 | */ 36 | 37 | export interface RoleState { 38 | amMasterRole: boolean; 39 | stateResolved: boolean; 40 | } 41 | 42 | const initialState: RoleState = { 43 | amMasterRole: false, 44 | stateResolved: false, 45 | }; 46 | 47 | export const name = 'role'; 48 | export const persistenceBlacklist: Array = Object.keys( 49 | initialState 50 | ) as Array; 51 | 52 | export const roleSlice = createSlice({ 53 | name, 54 | initialState, 55 | extraReducers: (builder) => builder.addCase(resetData, () => initialState), 56 | reducers: { 57 | establishRole: () => {}, 58 | setStateAsResolved: (state) => { 59 | state.stateResolved = true; 60 | }, 61 | _becomeMasterRole: (state) => { 62 | state.amMasterRole = true; 63 | }, 64 | _becomeClientRole: (state) => { 65 | state.amMasterRole = false; 66 | }, 67 | }, 68 | }); 69 | export const { 70 | establishRole, 71 | setStateAsResolved, 72 | _becomeMasterRole, 73 | _becomeClientRole, 74 | } = roleSlice.actions; 75 | 76 | export const selectedAmMasterRole = (state: RootState) => 77 | state.role.amMasterRole; 78 | export const selectedStateResolved = (state: RootState) => 79 | state.role.stateResolved; 80 | 81 | export default roleSlice.reducer; 82 | 83 | /** 84 | * Open websocket when role becomes master and state is resolved. 85 | * The role is necessary to avoid multiple websocket connections to Emacs. The 86 | * state is necessary to allow custom websocket ports. 87 | */ 88 | listenerMiddleware.startListening({ 89 | matcher: isAnyOf(_becomeMasterRole, setStateAsResolved), 90 | effect: (_action, listenerApi) => { 91 | const { dispatch } = listenerApi; 92 | const getState = listenerApi.getState.bind(this); 93 | const { 94 | role: { amMasterRole, stateResolved }, 95 | } = getState(); 96 | if (amMasterRole && stateResolved) { 97 | dispatch(_openWS()); 98 | } 99 | }, 100 | }); 101 | 102 | /** 103 | * Close websocket when role becomes client. 104 | */ 105 | listenerMiddleware.startListening({ 106 | actionCreator: _becomeClientRole, 107 | effect: (_action, listenerApi) => { 108 | const { dispatch } = listenerApi; 109 | dispatch(_closeWS()); 110 | }, 111 | }); 112 | 113 | /** 114 | * Everytime the port changes, restart the websocket as master or let the 115 | * master know it needs to. The master will not restart without the message 116 | * from client as the side effect is not triggered without an action (which the 117 | * storage sync doesn't trigger). 118 | */ 119 | listenerMiddleware.startListening({ 120 | actionCreator: setWSPortTo, 121 | effect: (action, listenerApi) => { 122 | const { dispatch } = listenerApi; 123 | const getState = listenerApi.getState.bind(this); 124 | const { 125 | role: { amMasterRole }, 126 | } = getState(); 127 | if (amMasterRole) { 128 | dispatch(_resetWS()); 129 | } else { 130 | const port = action.payload; 131 | sendToMasterTab(MsgToTabType.SET_WS_PORT, { port }); 132 | } 133 | }, 134 | }); 135 | 136 | /** 137 | * As master, send updates in responsesWaitingFor to other tabs 138 | * (not persisted in storage) 139 | */ 140 | listenerMiddleware.startListening({ 141 | matcher: isAnyOf( 142 | _addToResponsesWaitingFor, 143 | _removeFromResponsesWaitingFor, 144 | _setResponsesWaitingForTo 145 | ), 146 | effect: (_action, listenerApi) => { 147 | const getState = listenerApi.getState.bind(this); 148 | const { 149 | ws: { responsesWaitingFor }, 150 | role: { amMasterRole }, 151 | } = getState(); 152 | if (amMasterRole) { 153 | sendUpdateInWSState({ responsesWaitingFor }); 154 | } 155 | }, 156 | }); 157 | 158 | /** 159 | * As master, send updates in readyState to other tabs 160 | * (not persisted in storage) 161 | */ 162 | listenerMiddleware.startListening({ 163 | actionCreator: _setReadyStateTo, 164 | effect: (_action, listenerApi) => { 165 | const getState = listenerApi.getState.bind(this); 166 | const { 167 | ws: { readyState }, 168 | role: { amMasterRole }, 169 | } = getState(); 170 | if (amMasterRole) { 171 | sendUpdateInWSState({ readyState }); 172 | } 173 | }, 174 | }); 175 | 176 | /** 177 | * Every time the websocket opens, ask Emacs for the current item 178 | * (assuming master role) 179 | */ 180 | listenerMiddleware.startListening({ 181 | actionCreator: _setReadyStateTo, 182 | effect: (_action, listenerApi) => { 183 | const { dispatch } = listenerApi; 184 | const getState = listenerApi.getState.bind(this); 185 | const { 186 | emacs: { matchQuery }, 187 | role: { amMasterRole }, 188 | ws: { readyState }, 189 | } = getState(); 190 | if (matchQuery && amMasterRole && readyState === WSReadyState.OPEN) { 191 | dispatch(getItem()); 192 | } 193 | }, 194 | }); 195 | 196 | /** 197 | * Establish role using BGSW port. Further messages are sent into the 198 | * message slice/handler. 199 | */ 200 | listenerMiddleware.startListening({ 201 | actionCreator: establishRole, 202 | effect: () => { 203 | sendMsgToBGSWPort(MsgToBGSWType.QUERY_WS_ROLE, Port.port); 204 | }, 205 | }); 206 | -------------------------------------------------------------------------------- /src/modules/ui/uiSlice.ts: -------------------------------------------------------------------------------- 1 | import { Action, PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | import { listenerMiddleware } from 'app/middleware'; 3 | import { RootState } from 'app/store'; 4 | import { REHYDRATE } from '@plasmohq/redux-persist'; 5 | import { resetData } from 'app/actions'; 6 | 7 | export type OptionCategories = 'Behavior' | 'Layout' | 'Theming' | 'Debug'; 8 | 9 | export interface UIState { 10 | optionCategory: OptionCategories; 11 | untaggedItemBGColor: string; 12 | } 13 | 14 | export const name = 'ui'; 15 | export const persistenceBlacklist: Array = ['optionCategory']; 16 | 17 | const initialState: UIState = { 18 | optionCategory: 'Behavior', 19 | untaggedItemBGColor: '#484848', 20 | }; 21 | 22 | export const uiSlice = createSlice({ 23 | name, 24 | initialState, 25 | extraReducers: (builder) => builder.addCase(resetData, () => initialState), 26 | reducers: { 27 | setOptCatTo: (state, action: PayloadAction) => { 28 | state.optionCategory = action.payload; 29 | }, 30 | selectBehaviorOptCat: (state) => { 31 | state.optionCategory = 'Behavior'; 32 | }, 33 | selectLayoutOptCat: (state) => { 34 | state.optionCategory = 'Layout'; 35 | }, 36 | selectThemingOptCat: (state) => { 37 | state.optionCategory = 'Theming'; 38 | }, 39 | selectDebugOptCat: (state) => { 40 | state.optionCategory = 'Debug'; 41 | }, 42 | setUntaggedItemBGColor: (state, action: PayloadAction) => { 43 | state.untaggedItemBGColor = action.payload; 44 | }, 45 | resetUntaggedItemBGColor: (state) => { 46 | state.untaggedItemBGColor = initialState.untaggedItemBGColor; 47 | }, 48 | }, 49 | }); 50 | 51 | export const { 52 | setOptCatTo, 53 | selectBehaviorOptCat, 54 | selectLayoutOptCat, 55 | selectThemingOptCat, 56 | selectDebugOptCat, 57 | setUntaggedItemBGColor, 58 | resetUntaggedItemBGColor, 59 | } = uiSlice.actions; 60 | 61 | export const selectedOptionCategory = (state: RootState) => 62 | state.ui.optionCategory; 63 | export const selectedUntaggedItemBGColor = (state: RootState) => 64 | state.ui.untaggedItemBGColor; 65 | 66 | export default uiSlice.reducer; 67 | 68 | interface RehydrateUIState extends Action { 69 | type: typeof REHYDRATE; 70 | payload?: Partial; 71 | } 72 | 73 | type BGColorMiddlewareAction = 74 | | RehydrateUIState 75 | | ReturnType; 76 | 77 | /** 78 | * Set the item background color for untagged items (or items which have tags 79 | * that don't have corresponding colors). Will also be called on rehydrate. 80 | */ 81 | listenerMiddleware.startListening({ 82 | predicate: (action) => 83 | (action.type === REHYDRATE && action.key === 'ui') || 84 | action.type === setUntaggedItemBGColor.type, 85 | effect: (action) => { 86 | const { payload } = action as BGColorMiddlewareAction; 87 | let untaggedItemBGColor: string | undefined; 88 | if (action.type === REHYDRATE && typeof payload === 'object') { 89 | untaggedItemBGColor = payload?.untaggedItemBGColor; 90 | } else if (typeof payload === 'string') { 91 | untaggedItemBGColor = payload; 92 | } 93 | if (!untaggedItemBGColor) return; 94 | document.documentElement.style.setProperty( 95 | '--color-untagged-item-bg', 96 | untaggedItemBGColor 97 | ); 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /src/newtab/font.css: -------------------------------------------------------------------------------- 1 | /* Note: using custom modified fonts to avoid -webkit-text-stroke glitches. 2 | Fonts generated from .woff2 files, imported into FontForge, unions/overlaps 3 | merged, and re-generated as woff2 files into the assets folder. Unicode-range 4 | based on @fontsource CSS files. */ 5 | 6 | /* public-sans-latin-400-normal */ 7 | @font-face { 8 | font-family: 'Public Sans'; 9 | font-style: normal; 10 | font-display: swap; 11 | font-weight: 400; 12 | src: url('data-base64:~../assets/PublicSans-Regular.woff2') format('woff2'); 13 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 14 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, 15 | U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 16 | } 17 | 18 | /* public-sans-latin-700-normal */ 19 | @font-face { 20 | font-family: 'Public Sans'; 21 | font-style: normal; 22 | font-display: swap; 23 | font-weight: 700; 24 | src: url('data-base64:~../assets/PublicSans-Bold.woff2') format('woff2'); 25 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 26 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, 27 | U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 28 | } 29 | 30 | /* public-sans-latin-900-normal */ 31 | @font-face { 32 | font-family: 'Public Sans'; 33 | font-style: normal; 34 | font-display: swap; 35 | font-weight: 900; 36 | src: url('data-base64:~../assets/PublicSans-Black.woff2') format('woff2'); 37 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 38 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, 39 | U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 40 | } 41 | 42 | /* public-sans-latin-900-italic */ 43 | @font-face { 44 | font-family: 'Public Sans'; 45 | font-style: italic; 46 | font-display: swap; 47 | font-weight: 900; 48 | src: url('data-base64:~../assets/PublicSans-BlackItalic.woff2') 49 | format('woff2'); 50 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 51 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, 52 | U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 53 | } 54 | -------------------------------------------------------------------------------- /src/newtab/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-bg: #35373b; 3 | --color-untagged-item-bg: #484848; 4 | --color-item-shadow: black; 5 | --color-loading-bar: #29d; 6 | --color-options-menu-bg: hsl(0deg 0% 75%); 7 | --color-options-panel-text: black; 8 | --color-options-form-input: white; 9 | --color-options-form-input-box-shadow: rgba(0 0 0 / 25%); 10 | --color-ui: #fff; 11 | --opacity-ui: 0.55; 12 | --font-general: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 13 | Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 14 | 'Helvetica Neue', sans-serif; 15 | --font-mono: 'Source Code Pro', Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | --font-weight-light: 400; 18 | --font-weight-standard: 700; 19 | --font-weight-bold: 900; 20 | --width-item-stroke: 0.08rem; 21 | --animation-duration-options: 0.4s; 22 | --animation-timing-options: linear( 23 | 0 0%, 24 | 0 1.8%, 25 | 0.01 3.6%, 26 | 0.03 6.35%, 27 | 0.07 9.1%, 28 | 0.13 11.4%, 29 | 0.19 13.4%, 30 | 0.27 15%, 31 | 0.34 16.1%, 32 | 0.54 18.35%, 33 | 0.66 20.6%, 34 | 0.72 22.4%, 35 | 0.77 24.6%, 36 | 0.81 27.3%, 37 | 0.85 30.4%, 38 | 0.88 35.1%, 39 | 0.92 40.6%, 40 | 0.94 47.2%, 41 | 0.96 55%, 42 | 0.98 64%, 43 | 0.99 74.4%, 44 | 1 86.4%, 45 | 1 100% 46 | ); 47 | } 48 | 49 | body { 50 | background: var(--color-bg); 51 | font-size: 16px; 52 | margin: 0; 53 | font-family: var(--font-general); 54 | font-weight: var(--font-weight-standard); 55 | -webkit-font-smoothing: antialiased; 56 | -moz-osx-font-smoothing: grayscale; 57 | font-feature-settings: 'kern', 'liga', 'clig', 'calt', 'tnum'; 58 | font-kerning: normal; 59 | } 60 | 61 | code, 62 | pre { 63 | font-family: var(--font-mono); 64 | } 65 | 66 | pre { 67 | text-align: left; 68 | text-wrap: balance; 69 | } 70 | 71 | @media (prefers-reduced-motion) { 72 | *, 73 | *::before, 74 | *::after { 75 | animation: none !important; 76 | transition: none !important; 77 | } 78 | } 79 | 80 | .app { 81 | text-align: center; 82 | background-color: var(--color-bg); 83 | min-height: 100vh; 84 | display: flex; 85 | flex-direction: column; 86 | align-items: center; 87 | justify-content: space-between; 88 | font-size: 1rem; 89 | color: white; 90 | } 91 | -------------------------------------------------------------------------------- /src/newtab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | New Tab 5 | 6 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/newtab/index.tsx: -------------------------------------------------------------------------------- 1 | import '../lib/wdyr'; 2 | import { useEffect, useRef } from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { PersistGate } from '@plasmohq/redux-persist/integration/react'; 5 | import './font.css'; 6 | import './index.css'; 7 | import OptionsMenu from 'components/Options'; 8 | import LoadingBar from 'components/LoadingBar'; 9 | import store from '../app/store'; 10 | import '../app/storage'; 11 | import { useAppDispatch } from '../app/hooks'; 12 | import { setStateAsResolved } from 'modules/role/roleSlice'; 13 | import { initMessaging } from 'modules/msg/msgSlice'; 14 | import { Area } from '../modules/layout/layoutSlice'; 15 | import WidgetArea from 'components/WidgetArea'; 16 | import Time from 'components/Time'; 17 | import Persistor from 'lib/Persistor'; 18 | 19 | const IndexNewtab: React.FC = () => { 20 | const dispatch = useAppDispatch(); 21 | const isInitialRender = useRef(true); 22 | 23 | useEffect(() => { 24 | if (isInitialRender.current) { 25 | isInitialRender.current = false; 26 | dispatch(initMessaging()); 27 | } 28 | }, [dispatch]); 29 | 30 | useEffect(() => { 31 | document.title = chrome.i18n.getMessage('title'); 32 | }, []); 33 | 34 | return ( 35 |
36 | 37 | 38 | 39 | 40 | 41 |
43 | ); 44 | }; 45 | 46 | const StateResolver: React.FC<{ isInitialStateResolved: boolean }> = ({ 47 | isInitialStateResolved, 48 | }) => { 49 | const dispatch = useAppDispatch(); 50 | 51 | useEffect(() => { 52 | if (isInitialStateResolved) { 53 | dispatch(setStateAsResolved()); 54 | } 55 | }, [dispatch, isInitialStateResolved]); 56 | 57 | return null; 58 | }; 59 | 60 | const RootContextWrapper: React.FC = () => { 61 | return ( 62 | 63 | 64 | {(isInitialStateResolved) => { 65 | return ( 66 | 69 | ); 70 | }} 71 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | export default RootContextWrapper; 78 | -------------------------------------------------------------------------------- /test/org-newtab-test.el: -------------------------------------------------------------------------------- 1 | ;;; org-newtab-test.el --- Tests for org-newtab-*-lexical-binding:t-*- 2 | 3 | ;; Copyright (C) 2023-2024, Zweihänder 4 | ;; 5 | ;; Author: Zweihänder 6 | ;; Keywords: outlines 7 | ;; Homepage: https://github.com/Zweihander-Main/org-newtab 8 | ;; Version: 0.0.4 9 | 10 | ;; SPDX-License-Identifier: AGPL-3.0-or-later 11 | 12 | ;; This file is not part of GNU Emacs. 13 | 14 | ;;; License: 15 | 16 | ;; This program is free software: you can redistribute it and/or modify 17 | ;; it under the terms of the GNU Affero General Public License as published 18 | ;; by the Free Software Foundation, either version 3 of the License, or 19 | ;; (at your option) any later version. 20 | ;; 21 | ;; This program is distributed in the hope that it will be useful, 22 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | ;; GNU Affero General Public License for more details. 25 | ;; 26 | ;; You should have received a copy of the GNU Affero General Public License 27 | ;; along with this program. If not, see . 28 | 29 | ;;; Commentary: 30 | 31 | ;; Tests for org-newtab 32 | 33 | ;;; Code: 34 | 35 | (require 'buttercup) 36 | (require 'org-newtab) 37 | 38 | (describe "Testing" 39 | (it "works." 40 | (expect t :to-equal t))) 41 | 42 | (describe "Converting colors" 43 | (it "works with hex provided colors" 44 | (expect (org-newtab--string-color-to-hex "#0000FF") :to-equal "#0000FF")) 45 | (it "works with named provided colors" 46 | (expect (org-newtab--string-color-to-hex "blue") :to-equal "#0000ff")) 47 | (it "works with nil" 48 | (expect (org-newtab--string-color-to-hex nil) :to-equal nil))) 49 | 50 | (describe "Fetching tags" 51 | (it "works with default faces" 52 | (let ((org-tag-faces '(("1#A" . diary) 53 | ("2#B" . org-warning)))) 54 | (expect (org-newtab--get-tag-faces) :to-equal 55 | '(("1#A") ;; Face-foreground has term frame in buttercup test 56 | ("2#B"))))) 57 | 58 | (it "works with hex provided colors" 59 | (let ((org-tag-faces '(("1#A" . "#0000FF") 60 | ("2#B" . "#f0c674")))) 61 | (expect (org-newtab--get-tag-faces) :to-equal 62 | '(("1#A" . "#0000FF") 63 | ("2#B" . "#f0c674"))))) 64 | 65 | (it "works with named provided colors" 66 | (let ((org-tag-faces '(("1#A" . "blue") 67 | ("2#B" . "yellow1")))) 68 | (expect (org-newtab--get-tag-faces) :to-equal 69 | '(("1#A" . "#0000ff") 70 | ("2#B" . "#ffff00"))))) 71 | 72 | (it "works with hex foreground faces" 73 | (let ((org-tag-faces '(("1#A" :foreground "#42A5F5" :weight bold :italic t) 74 | ("2#B" :foreground "#CC2200" :weight bold :underline t)))) 75 | (expect (org-newtab--get-tag-faces) :to-equal 76 | '(("1#A" . "#42A5F5") 77 | ("2#B" . "#CC2200"))))) 78 | 79 | (it "works with named provided colors as foreground faces" 80 | (let ((org-tag-faces '(("1#A" :foreground "yellow1" :weight bold) 81 | ("2#B" :foreground "green yellow" :weight bold) 82 | ("3#C" :foreground "blue" :weight bold :underline t)))) 83 | (expect (org-newtab--get-tag-faces) :to-equal 84 | '(("1#A" . "#ffff00") 85 | ("2#B" . "#ffff00") ;; Running in term 86 | ("3#C" . "#0000ff"))))) 87 | 88 | (it "doesn't break on empty foreground face" 89 | (let ((org-tag-faces `(("1#A" :weight bold)))) 90 | (expect (org-newtab--get-tag-faces) :to-equal '(("1#A"))))) 91 | 92 | (it "doesn't break on nil" 93 | (let ((org-tag-faces nil)) 94 | (expect (org-newtab--get-tag-faces) :to-equal nil)))) 95 | 96 | (describe "Subscriptions to the store" 97 | (before-each 98 | (org-newtab--clear-subscribers)) 99 | 100 | (it "allows subscribing one item" 101 | (org-newtab--subscribe 'ext-get-item #'org-edit-headline) 102 | (org-newtab--subscribe 'ext-get-item #'org-edit-special) 103 | (org-newtab--subscribe 'ext-get-item2 #'org-edit-src-code) 104 | (org-newtab--subscribe 'ext-get-item2 #'org-edit-headline) 105 | (expect org-newtab--action-subscribers 106 | :to-have-same-items-as 107 | '((ext-get-item org-edit-special org-edit-headline) 108 | (ext-get-item2 org-edit-headline org-edit-src-code) 109 | nil))) 110 | 111 | (it "avoid duplicates" 112 | (org-newtab--subscribe 'ext-get-item #'org-edit-headline) 113 | (org-newtab--subscribe 'ext-get-item #'org-edit-headline) 114 | (expect org-newtab--action-subscribers 115 | :to-equal 116 | '((ext-get-item org-edit-headline) 117 | nil))) 118 | 119 | (it "allows unsubscribing" 120 | (org-newtab--subscribe 'ext-get-item #'org-edit-headline) 121 | (org-newtab--subscribe 'ext-get-item #'org-edit-special) 122 | (org-newtab--unsubscribe 'ext-get-item #'org-edit-headline) 123 | (expect org-newtab--action-subscribers 124 | :to-equal '((ext-get-item org-edit-special) nil)))) 125 | 126 | (provide 'org-newtab-test) 127 | 128 | ;; Local Variables: 129 | ;; coding: utf-8 130 | ;; End: 131 | 132 | ;;; org-newtab-test.el ends here 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "allowSyntheticDefaultImports": true, 6 | "baseUrl": "src", 7 | "experimentalDecorators": true, 8 | "importHelpers": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "dist", 14 | "rootDir": ".", 15 | "sourceMap": true, 16 | "strict": true, 17 | "typeRoots": ["./.typings", "./node_modules/@types"], 18 | "types": ["chrome", "jest"], 19 | "isolatedModules": true, 20 | "useDefineForClassFields": true, 21 | "verbatimModuleSyntax": false, 22 | "paths": { 23 | "~*": ["./src/*"] 24 | } 25 | }, 26 | "baseUrl": ".", 27 | "include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"], 28 | "exclude": ["node_modules"] 29 | } 30 | --------------------------------------------------------------------------------