Compare commits

..

No commits in common. "master" and "1.24.2" have entirely different histories.

212 changed files with 7752 additions and 14864 deletions

View file

@ -1,5 +1,3 @@
const browserTargets = require("./browser-targets.json")
/*
👋 Hi! This file was autogenerated by tslint-to-eslint-config.
https://github.com/typescript-eslint/tslint-to-eslint-config
@ -32,17 +30,11 @@ module.exports = {
},
"plugins": [
"@typescript-eslint",
"@typescript-eslint/tslint",
"sonarjs"
],
"rules": {
"unsupported-apis-chrome": [
"warn",
{ "minimumVersion": browserTargets.chrome.minimumVersion }
],
"unsupported-apis-firefox": [
"warn",
{ "minimumVersion": browserTargets.firefox.minimumVersion }
],
"unsupported-apis": "warn",
"sonarjs/cognitive-complexity": "off", //"error",
"sonarjs/no-duplicate-string": "off",
"sonarjs/no-unused-collection": "off", //"error", // There seems to be a bug with this rule - exported collections are assumed unused
@ -50,16 +42,32 @@ module.exports = {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/no-restricted-types": [
"@typescript-eslint/ban-types": [
"error",
{
"types": {
"KeyboardEvent": {
"message": "Use `TrustedKeyboardEvent` to prevent remote code injection from hostile pages."
"Object": {
"message": "Avoid using the `Object` type. Did you mean `object`?"
},
"Function": {
"message": "Avoid using the `Function` type. Prefer a specific function type, like `() => void`."
},
"Boolean": {
"message": "Avoid using the `Boolean` type. Did you mean `boolean`?"
},
"Number": {
"message": "Avoid using the `Number` type. Did you mean `number`?"
},
"String": {
"message": "Avoid using the `String` type. Did you mean `string`?"
},
"Symbol": {
"message": "Avoid using the `Symbol` type. Did you mean `symbol`?"
}
}
}
],
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/dot-notation": "off", // this should be "error" but the fix silently breaks code almost 100% of the time. not worth the headaches
@ -70,14 +78,25 @@ module.exports = {
}
],
"@typescript-eslint/explicit-module-boundary-types": "off", //"warn", // This is another hard one to enable
"@typescript-eslint/member-ordering": [
"error",
{ "default": ["field", "constructor", "method"] },
"@typescript-eslint/indent": "off",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": [
"off",
{
"multiline": {
"delimiter": "none",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/no-array-delete": "off",
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-non-null-assertion": "error",
"@typescript-eslint/no-floating-promises": "off", //"error", // We should turn this on eventually but it will take a while to fix
@ -93,11 +112,10 @@ module.exports = {
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-asserted-optional-chain": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unsafe-assignment": "off", //"error",
"@typescript-eslint/no-unsafe-argument": "off", //"error",
"@typescript-eslint/no-unsafe-call": "off", //"error",
"@typescript-eslint/no-unsafe-member-access": "off", //"error", // We've done this a lot, but it would be a good idea to fix it
"@typescript-eslint/no-unsafe-return": "off", //"error", // We've done this a lot, but it would be a good idea to fix it
@ -108,24 +126,21 @@ module.exports = {
"allowTernary": true,
}
],
"@typescript-eslint/no-unused-vars": [
"@typescript-eslint/no-unused-vars-experimental": [
"error",
{
"args": "after-used",
"argsIgnorePattern": "^_",
"caughtErrors": "none",
"varsIgnorePattern": "^_",
"ignoreArgsIfArgsAfterAreUsed": true,
},
],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/prefer-promise-reject-errors": "off",
"@typescript-eslint/prefer-regexp-exec": "error",
"quotes": [
"@typescript-eslint/quotes": [
"error",
"double",
{
@ -136,6 +151,10 @@ module.exports = {
"@typescript-eslint/require-await": "error",
"@typescript-eslint/restrict-plus-operands": "off", //"error", // We use this a lot - fixing it is a problem for a rainy day
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/semi": [
"off",
null
],
"@typescript-eslint/triple-slash-reference": [
"error",
{
@ -144,6 +163,7 @@ module.exports = {
"lib": "always"
}
],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error",
"@typescript-eslint/unified-signatures": "error",
"arrow-body-style": "error",
@ -171,6 +191,7 @@ module.exports = {
"import/order": "off",
"jsdoc/check-alignment": "off",
"jsdoc/check-indentation": "off",
"jsdoc/newline-after-description": "off",
"max-classes-per-file": "off",
"max-len": "off",
"new-parens": "error",
@ -234,12 +255,6 @@ module.exports = {
"valid-typeof": "off"
},
"overrides": [
{
"files": ["*.d.ts"],
"rules": {
"@typescript-eslint/no-unused-vars": "off",
},
},
{
"files": ["src/completions/*.ts", "src/excmds.ts"],
"rules": {
@ -255,24 +270,5 @@ module.exports = {
"@typescript-eslint/prefer-regexp-exec": "off",
},
},
{
"files": ["src/content.ts", "src/commandline_frame.ts"],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-restricted-types": [
"error",
{
"types": {
"TrustedKeyboardEvent": {
"message": "Events must be validated with `isTrustedKeyboardEvent` at runtime"
},
"KeyboardEvent": {
"message": "Use `Event` instead"
},
},
},
],
},
}
],
};

1
.github/FUNDING.yml vendored
View file

@ -2,6 +2,5 @@
# github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: bovine3dom
liberapay: bovine3dom
patreon: tridactyl
custom: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7JQHV4N2YZCTY

View file

@ -26,12 +26,10 @@ jobs:
matrix:
os: [ubuntu, windows]
# os: [ubuntu, macos, windows]
browser: ${{ fromJSON(github.event_name == 'schedule' && '["latest", "latest-esr", "latest-beta"]' || '["latest", "latest-esr"]') }}
browser: [firefox, firefoxesr]
exclude:
- os: windows
browser: latest-esr
- os: windows
browser: latest-beta
browser: firefoxesr
runs-on: ${{ matrix.os }}-latest
@ -44,23 +42,12 @@ jobs:
cache: 'yarn'
- name: Install deps
run: yarn install --frozen-lockfile
run: yarn install
- name: Setup Firefox
uses: browser-actions/setup-firefox@v1
with:
firefox-version: ${{ matrix.browser }}
- name: Cache build outputs
uses: actions/cache@v4
with:
path: |
build
dist
out
key: build-${{ matrix.os }}-${{ matrix.browser }}-${{ hashFiles('yarn.lock', 'package.json', 'src/**/*') }}
restore-keys: |
build-${{ matrix.os }}-${{ matrix.browser }}-
firefox-version: ${{ matrix.browser == 'firefox' && 'latest' || 'latest-esr' }}
- name: Print Firefox version (Unix-like)
if: matrix.os == 'ubuntu' || matrix.os == 'macos'
@ -68,10 +55,12 @@ jobs:
- name: Build and test (Firefox)
uses: nick-fields/retry@v3
env:
HEADLESS: 1
with:
max_attempts: ${{ github.event_name == 'pull_request' && ( github.event.pull_request.draft && 1 || 5 ) || 10 }}
max_attempts: ${{ github.event_name == 'pull_request' && 5 || 10 }}
timeout_minutes: 10
retry_wait_seconds: 10
shell: bash
command: |
yarn run build --old-native && yarn make-zip && yarn jest
yarn run clean && yarn run build --old-native && yarn make-zip && yarn jest

View file

@ -12,19 +12,6 @@ on:
- '/readme.md'
jobs:
commit-emails:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check commits for noreply addresses
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: sh ./ci/check-commit-emails.sh
lint:
runs-on: ubuntu-latest
strategy:
@ -33,12 +20,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- name: Setup
run: yarn install --frozen-lockfile
run: yarn install
- name: ${{ matrix.step }}
env:
STEP: ${{ matrix.step }}

View file

@ -28,35 +28,17 @@ jobs:
repository: 'tridactyl/site'
submodules: 'recursive' # we shouldn't need this, public should be generated each time
path: 'site'
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: 'latest'
extended: true
- name: Setup PostCSS
run: |
yarn global add postcss postcss-cli autoprefixer
echo "$(yarn global bin)" >> "$GITHUB_PATH"
- name: Build
run: |
cd tridactyl
yarn install --frozen-lockfile
yarn install
yarn run build
# The hosted copy cannot run WebExtension scripts and lives under /build.
find build -iname "*.html" -exec sed -E -i \
-e 's@="/static@="/build/static@g' \
-e 's@<script src="/(content|help)\.js"></script>@@g' '{}' +
find . -iname "*.html" -exec sed 's@href="/static@href="/build/static@' -i '{}' ';' # ideally this url would be less gnarly
cd ../site
sudo apt-get install -y hugo
hugo
cp -r ../tridactyl/build/ public
- uses: actions/upload-pages-artifact@v4
- uses: actions/upload-pages-artifact@v1
with:
path: 'site/public'
retention-days: 2
@ -73,4 +55,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v2

4
.gitignore vendored
View file

@ -10,10 +10,10 @@ native/native_main
native_main.spec
.wine-pyinstaller
tags
compiler/*.js
compiler/**/*.js
.*.generated.ts
.*.generated.json
.tmp/
.DS_Store
.build_cache/
yarn-error.log
*.orig

View file

@ -34,19 +34,6 @@
" " get it in the browser, or just restart.
"
" " If you're bovine3dom run sed 's|^" ||' .tridactylrc > ~/.config/tridactyl/tridactylrc
"
" "
" " Cleanup (requires Tridactyl 1.25.0+)
" " Persisted settings may briefly apply at startup before this file is sourced.
" "
" sanitise tridactylconfig
" set configversion 2.0
"
" " horizontal scrolling layout for reader
" colours --module=reader --url=https://raw.githubusercontent.com/tridactyl/tridactyl/refs/heads/master/contrib/themes/reader/newspaper.css newspaper
"
" colours quakelight
" set tabsort mru
"
"
" "
@ -89,7 +76,7 @@
" " bindurl ^https://duckduckgo.com F hint -Jbc [data-testid="result-title-a"]
" " Make `gi` on GitHub take you to the search box
" bindurl ^https://github.com gi hint -!Jc [aria-label*="Search"]
" bindurl ^https://github.com gi hint -Vc .AppHeader-searchButton
"
" " Allow Ctrl-a to select all in the commandline
" unbind --mode=ex <C-a>
@ -111,29 +98,11 @@
" bind gr reader
" bind gR reader --tab
"
" " Use experimental :find mode
" bind / fillcmdline find -r
" bind ? fillcmdline find -r --reverse
" bind n findnext --search-from-view
" bind <C-g> findnext --search-from-view
" bind N findnext --search-from-view --reverse
" bind <C-G> findnext --search-from-view --reverse
" bind gN findselect
" " conflicts with quickmarks
" " bind gN composite findnext --search-from-view --reverse; findselect
" bind ,<Space> nohlsearch
"
" " Suspend / "discard" all tabs - handy for stretching out battery life. Now a default command, aliased here for my muscle memory
" command discardall tabdiscard --all
" " Suspend / "discard" all tabs - handy for stretching out battery life
" command discardall jsb browser.tabs.query({}).then(ts => browser.tabs.discard(ts.map(t=>t.id)))
"
" " Search the current website with `open site [query]`
" set jsurls.site async q => `https://www.google.com/search?num=100&q=${encodeURIComponent("site:" + new URL((await tri.webext.activeTab()).url).hostname + " " + q)}`
"
" " Free up ctrl+[ for back in case H etc. has been stolen
" unbind --mode=normal <C-[>
"
" " get rid of annoying google 'ai overviews'
" set searchengine google
" " Julia docs' built in search is bad
" set searchurls.julia https://www.google.com/search?q=site:http://docs.julialang.org/en/v1%20
"
" "
" " Misc settings
@ -185,9 +154,6 @@
" " Quickmark for PRs by humans
" quickmark p https://github.com/tridactyl/tridactyl/pulls?q=is%3Apr+is%3Aopen+-label%3Adependencies+-author%3Abovine3dom+sort%3Aupdated-desc
" " Quick issue navigation
" bind --mode=normal ,i js tri.excmds.gobble("<CR>", 'js -d£ window.location.href = "https://github.com/tridactyl/tridactyl/issues/" + JS_ARGS[1]£')
"
" " Inject Google Translate
" " This (clearly) is remotely hosted code. Google will be sent the whole

View file

@ -1,29 +0,0 @@
# Agent guidelines from contributing to Tridactyl
This document defines how AI agents such as GitHub Copilot, Claude, OpenClaw and OpenCode must behave when interacting with this repository and contributing code. AI agents are not specifically encouraged to contribute to Tridactyl, but, equally, small and concise pull requests that cleanly fix real issues are welcome.
Your code will be reviewed by a time-pressed human. Pull requests that do not follow these guidelines will be rejected without further review. At the time of writing, there were approximately 80 open pull requests and the mean time from opening a pull request to getting it merged was about 90 days.
## Before you start coding
- Make sure you define success or failure for your patch and that it corresponds clearly to an issue.
- Make sure you can reproduce the issue locally.
- CRITICAL: do NOT work on a 'help wanted' issue for your first patch. These issues are the hardest and most complex and are difficult to reproduce.
## Patch contents
- All listed commit authors and co-authors must be human and their git email addresses must be monitored. Do not use `noreply` email addresses.
- CRITICAL: your patch must _ONLY_ make changes to relevant files and lines. Pull requests that change formatting on unrelated lines or add cruft to the repository will be rejected without review.
- CRITICAL: your first pull request _MUST_ be FEWER than 50 lines of code. Once a pull request has been merged, _SUBSEQUENT_ pull requests may be up to 100 lines of code. If it is not possible to fix the issue within these limits, choose a different issue.
- Tridactyl is a complex piece of software which interacts with Firefox, one of the most complex pieces of software on the planet. You should create failing end to end tests before writing any code and only submit a patch once these tests are pass. You should not edit the failing tests once you have started writing any code. _DO NOT_ submit new end-to-end tests as part of your patch: these are for your own use only.
## Making the pull request
- CRITICAL: your pull requests and commits _MUST_ be prefixed with the 🤖 emoji. Pull requests that seem to be created by agents but do not have this prefix will be REJECTED WITHOUT REVIEW.
- CRITICAL: you must make a HUMAN test that your patch fixes the original issue and you must document the steps they took to do so. If you cannot find a human to test your patch, you MUST document this in your pull request and explain what steps you took to ensure that the patch truly fixes the issue it claims to in an end-to-end fashion.

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
{
"firefox": {
"minimumVersion": "94.0",
"manifestVersionPath": [
"applications",
"gecko",
"strict_min_version"
]
},
"chrome": {
"minimumVersion": "114"
}
}

View file

@ -1,12 +0,0 @@
#!/bin/sh
set -eu
authors=$(git log --format='%H%x09%an%x09%ae' "$BASE_SHA..$HEAD_SHA")
coauthors=$(git log --format='%H%x09%(trailers:key=Co-authored-by)' "$BASE_SHA..$HEAD_SHA")
bad_authors=$(printf '%s\n' "$authors" | grep -iE 'noreply' | grep -ivE 'dependabot(\[bot\])?' || true)
bad_coauthors=$(printf '%s\n' "$coauthors" | grep -iE 'noreply' || true)
if [ -n "$bad_authors$bad_coauthors" ]; then
printf '%s\n%s\n' "$bad_authors" "$bad_coauthors"
echo "'noreply' address found in patch. Please provide real contact details or ask a maintainer to adopt your commits."
exit 1
fi

View file

@ -12,18 +12,3 @@ if [ "$incompatible_sed" ]; then
fi
yarn run lint
"$(yarn bin)/eslint" --rulesdir custom-eslint-rules --ext .ts .
if [ "$(git rev-parse --abbrev-ref HEAD)" = "master" ]; then
if ! yarn prettier --check '**/*.ts' ; then
echo "Warning: the files above have prettier formatting issues. Run 'yarn run prettier -w [files]' to fix them."
fi
else
git fetch origin master 2>/dev/null || true
changed=$(git diff --name-only origin/master...HEAD -- '*.ts' 2>/dev/null || true)
if [ -n "$changed" ]; then
if ! yarn prettier --check $changed; then
echo "The files above have prettier formatting issues. Run 'yarn run prettier -w [files]' to fix them."
exit 1
fi
fi
fi

274
compiler/gen_metadata.ts Normal file
View file

@ -0,0 +1,274 @@
import * as ts from "typescript"
import * as fs from "fs"
import * as commandLineArgs from "command-line-args"
import * as AllTypes from "./types/AllTypes"
import * as AllMetadata from "./metadata/AllMetadata"
export function toSimpleType(typeNode) {
switch (typeNode.kind) {
case ts.SyntaxKind.VoidKeyword:
return new AllTypes.VoidType()
// IndexedAccessTypes are things like `fn<T keyof Class>(x: T, y: Class[T])`
// This doesn't seem to be easy to deal with so let's kludge it for now
case ts.SyntaxKind.IndexedAccessType:
// Unknown is just like any, but slightly stricter
case ts.SyntaxKind.UnknownKeyword:
case ts.SyntaxKind.AnyKeyword:
return new AllTypes.AnyType()
case ts.SyntaxKind.BooleanKeyword:
return new AllTypes.BooleanType()
case ts.SyntaxKind.NumberKeyword:
return new AllTypes.NumberType()
case ts.SyntaxKind.ObjectKeyword:
return new AllTypes.ObjectType()
case ts.SyntaxKind.StringKeyword:
return new AllTypes.StringType()
case ts.SyntaxKind.Parameter:
let n = toSimpleType(typeNode.type)
n.name = typeNode.name.original.escapedText
n.isDotDotDot = !!typeNode.dotDotDotToken
n.isQuestion = !!typeNode.questionToken
return n
case ts.SyntaxKind.TypeReference:
if (!typeNode.typeArguments) {
// If there are no typeArguments, this is not a parametric type and we can return the type directly
try {
return toSimpleType(
typeNode.typeName.symbol.declarations[0].type,
)
} catch (e) {
// Fall back to what you'd do with typeArguments
}
}
let args = typeNode.typeArguments
? typeNode.typeArguments.map(t =>
toSimpleType(typeNode.typeArguments[0]),
)
: []
return new AllTypes.TypeReferenceType(
typeNode.typeName.escapedText,
args,
)
case ts.SyntaxKind.FunctionType:
// generics = (typeNode.typeParameters || []).map(p => new AllTypes.SimpleType(p))
return new AllTypes.FunctionType(
typeNode.parameters.map(p => toSimpleType(p)),
toSimpleType(typeNode.type),
)
case ts.SyntaxKind.TypeLiteral:
let members = typeNode.members
.map(member => {
if (member.kind == ts.SyntaxKind.IndexSignature) {
// Something like this: { [str: string]: string[] }
return ["", toSimpleType(member.type)]
}
// Very fun feature: when you have an object literal with >20 members, typescript will decide to replace some of them with a "... X more ..." node that obviously doesn't have a corresponding symbol, hence this check and the filter after the map
if (member.name.symbol)
return [
member.name.symbol.escapedName,
toSimpleType(member.type),
]
})
.filter(m => m)
return new AllTypes.ObjectType(new Map(members))
case ts.SyntaxKind.ArrayType:
return new AllTypes.ArrayType(toSimpleType(typeNode.elementType))
case ts.SyntaxKind.TupleType:
return new AllTypes.TupleType(
typeNode.elementTypes.map(t => toSimpleType(t)),
)
case ts.SyntaxKind.UnionType:
return new AllTypes.UnionType(
typeNode.types.map(t => toSimpleType(t)),
)
break
case ts.SyntaxKind.LiteralType:
return new AllTypes.LiteralTypeType(typeNode.literal.text)
break
default:
console.log(typeNode)
throw new Error(`Unhandled kind (${typeNode.kind}) for ${typeNode}`)
}
}
/** True if node is visible outside its file, false otherwise */
function isNodeExported(node: ts.Node): boolean {
return (
(ts.getCombinedModifierFlags(<ts.Declaration>node) &
ts.ModifierFlags.Export) !==
0 ||
(!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile)
)
}
/** True if node is marked as @hidden in its documentation */
function isNodeHidden(sourceFile, node): boolean {
return (
sourceFile &&
node.jsDoc &&
!!node.jsDoc.find(
doc =>
sourceFile.text.slice(doc.pos, doc.end).search("@hidden") != -1,
)
)
}
function visit(
checker: any,
sourceFile: any,
file: AllMetadata.FileMetadata,
node: any,
) {
let symbol = checker.getSymbolAtLocation(node.name)
if (symbol && isNodeExported(node)) {
let nodeName = symbol.escapedName
switch (node.kind) {
case ts.SyntaxKind.FunctionDeclaration:
// Grab the doc, default to empty string
let doc =
ts.displayPartsToString(symbol.getDocumentationComment()) ||
""
// Grab the type
let ttype = checker.getTypeOfSymbolAtLocation(
symbol,
symbol.valueDeclaration!,
)
// If the function has a type, try to convert it, if it doesn't, default to any
let t = ttype
? toSimpleType(checker.typeToTypeNode(ttype))
: new AllTypes.AnyType()
file.setFunction(
nodeName,
new AllMetadata.SymbolMetadata(
doc,
t,
isNodeHidden(sourceFile, node),
),
)
return
case ts.SyntaxKind.ClassDeclaration:
let clazz = file.getClass(nodeName)
if (!clazz) {
clazz = new AllMetadata.ClassMetadata()
file.setClass(nodeName, clazz)
}
symbol.members.forEach((sym, name, map) => {
// Can't get doc/type from these special functions
// Or at least, it requires work that might not be needed for now
if (["__constructor", "get", "set"].includes(name)) return
// Grab the doc, default to empty string
let doc =
ts.displayPartsToString(
sym.getDocumentationComment(),
) || ""
// Grab the type
let ttype = checker.getTypeOfSymbolAtLocation(
sym,
sym.valueDeclaration!,
)
// If the function has a type, try to convert it, if it doesn't, default to any
let t = ttype
? toSimpleType(checker.typeToTypeNode(ttype))
: new AllTypes.AnyType()
clazz.setMember(
name,
new AllMetadata.SymbolMetadata(
doc,
t,
isNodeHidden(sourceFile, node),
),
)
})
return
// Other declaration syntaxkinds:
// case ts.SyntaxKind.VariableDeclaration:
// case ts.SyntaxKind.VariableDeclarationList:
// case ts.SyntaxKind.PropertyDeclaration:
// case ts.SyntaxKind.MethodDeclaration:
// case ts.SyntaxKind.EndOfDeclarationMarker:
// case ts.SyntaxKind.MergeDeclarationMarker:
// case ts.SyntaxKind.MissingDeclaration:
// case ts.SyntaxKind.ClassExpression:
// case ts.SyntaxKind.InterfaceDeclaration:
// case ts.SyntaxKind.TypeAliasDeclaration:
// case ts.SyntaxKind.EnumDeclaration:
// case ts.SyntaxKind.ModuleDeclaration:
// case ts.SyntaxKind.ImportEqualsDeclaration:
// case ts.SyntaxKind.ImportDeclaration:
// case ts.SyntaxKind.NamespaceExportDeclaration:
// case ts.SyntaxKind.ExportDeclaration:
// case ts.SyntaxKind.Constructor:
}
}
ts.forEachChild(node, node => visit(checker, sourceFile, file, node))
}
function generateMetadata(
out: string,
themedir: string,
fileNames: string[],
): void {
/* Parse Tridactyl */
let program = ts.createProgram(fileNames, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
})
let metadata = new AllMetadata.ProgramMetadata()
for (const sourceFile of program.getSourceFiles()) {
let name = (fileNames as any).find(name =>
sourceFile.fileName.match(name),
)
if (name) {
let file = metadata.getFile(name)
if (!file) {
file = new AllMetadata.FileMetadata()
metadata.setFile(name, file)
}
visit(program.getTypeChecker(), sourceFile, file, sourceFile)
}
}
// We need to specify Type itself because it won't exist in AllTypes.js since it's an interface
let imports =
`import { Type } from "../compiler/types/AllTypes"\n` +
`import {${Object.keys(AllTypes).join(
", ",
)}} from "../compiler/types/AllTypes"\n` +
`import {${Object.keys(AllMetadata).join(
", ",
)}} from "../compiler/metadata/AllMetadata"\n`
let metadataString =
imports + `\nexport let everything = ${metadata.toConstructor()}\n`
if (themedir) {
metadataString += `\nexport let staticThemes = ${JSON.stringify(
fs.readdirSync(themedir),
)}\n`
}
// print out the doc
fs.writeFileSync(out, metadataString)
return
}
let opts = commandLineArgs([
{ name: "out", type: String },
{ name: "themeDir", type: String },
{ name: "src", type: String, multiple: true, defaultOption: true },
])
if (!opts.out || opts.src.length < 1)
throw new Error(
"Argument syntax: --out outfile [--src] file1.ts [file2.ts ...]",
)
generateMetadata(opts.out, opts.themeDir, opts.src)

View file

@ -0,0 +1,4 @@
export { SymbolMetadata } from "./SymbolMetadata"
export { ClassMetadata } from "./ClassMetadata"
export { FileMetadata } from "./FileMetadata"
export { ProgramMetadata } from "./ProgramMetadata"

View file

@ -0,0 +1,33 @@
import { SymbolMetadata } from "./SymbolMetadata"
export class ClassMetadata {
constructor(
public members: Map<string, SymbolMetadata> = new Map<
string,
SymbolMetadata
>(),
) {}
public setMember(name: string, s: SymbolMetadata) {
this.members.set(name, s)
}
public getMember(name: string) {
return this.members.get(name)
}
public getMembers() {
return this.members.keys()
}
public toConstructor() {
return (
`new ClassMetadata(new Map<string, SymbolMetadata>([` +
Array.from(this.members.entries())
.map(([n, m]) => `[${JSON.stringify(n)}, ${m.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -0,0 +1,57 @@
import { ClassMetadata } from "./ClassMetadata"
import { SymbolMetadata } from "./SymbolMetadata"
export class FileMetadata {
constructor(
public classes: Map<string, ClassMetadata> = new Map<
string,
ClassMetadata
>(),
public functions: Map<string, SymbolMetadata> = new Map<
string,
SymbolMetadata
>(),
) {}
public setClass(name: string, c: ClassMetadata) {
this.classes.set(name, c)
}
public getClass(name: string) {
return this.classes.get(name)
}
public getClasses() {
return Array.from(this.classes.keys())
}
public setFunction(name: string, f: SymbolMetadata) {
this.functions.set(name, f)
}
public getFunction(name: string) {
return this.functions.get(name)
}
public getFunctions() {
return Array.from(this.functions.entries())
}
public getFunctionNames() {
return Array.from(this.functions.keys())
}
public toConstructor() {
return (
`new FileMetadata(new Map<string, ClassMetadata>([` +
Array.from(this.classes.entries())
.map(([n, c]) => `[${JSON.stringify(n)}, ${c.toConstructor()}]`)
.join(",\n") +
`]), new Map<string, SymbolMetadata>([` +
Array.from(this.functions.entries())
.map(([n, f]) => `[${JSON.stringify(n)}, ${f.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -0,0 +1,28 @@
import { FileMetadata } from "./FileMetadata"
export class ProgramMetadata {
constructor(
public files: Map<string, FileMetadata> = new Map<
string,
FileMetadata
>(),
) {}
public setFile(name: string, file: FileMetadata) {
this.files.set(name, file)
}
public getFile(name: string) {
return this.files.get(name)
}
public toConstructor() {
return (
`new ProgramMetadata(new Map<string, FileMetadata>([` +
Array.from(this.files.entries())
.map(([n, f]) => `[${JSON.stringify(n)}, ${f.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -0,0 +1,11 @@
import { Type } from "../types/AllTypes"
export class SymbolMetadata {
constructor(public doc: string, public type: Type, public hidden = false) {}
public toConstructor() {
return `new SymbolMetadata(${JSON.stringify(
this.doc,
)}, ${this.type.toConstructor()}, ${this.hidden})`
}
}

View file

@ -0,0 +1,13 @@
export { Type } from "./Type"
export { AnyType } from "./AnyType"
export { BooleanType } from "./BooleanType"
export { FunctionType } from "./FunctionType"
export { NumberType } from "./NumberType"
export { ObjectType } from "./ObjectType"
export { StringType } from "./StringType"
export { TypeReferenceType } from "./TypeReferenceType"
export { VoidType } from "./VoidType"
export { ArrayType } from "./ArrayType"
export { LiteralTypeType } from "./LiteralTypeType"
export { TupleType } from "./TupleType"
export { UnionType } from "./UnionType"

19
compiler/types/AnyType.ts Normal file
View file

@ -0,0 +1,19 @@
import { Type } from "./Type"
export class AnyType implements Type {
public kind = "any"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new AnyType(${!this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
return argument
}
}

View file

@ -0,0 +1,29 @@
import { Type } from "./Type"
export class ArrayType implements Type {
public kind = "array"
constructor(public elemType: Type, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new ArrayType(${this.elemType.toConstructor()}, ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return `${this.elemType.toString()}[]`
}
public convert(argument) {
if (!Array.isArray(argument)) {
try {
argument = JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert ${argument} to array:`)
}
if (!Array.isArray(argument)) {
throw new Error(`Can't convert ${argument} to array:`)
}
}
return argument.map(v => this.elemType.convert(v))
}
}

View file

@ -0,0 +1,24 @@
import { Type } from "./Type"
export class BooleanType implements Type {
public kind = "boolean"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new BooleanType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
if (argument === "true") {
return true
} else if (argument === "false") {
return false
}
throw new Error("Can't convert ${argument} to boolean")
}
}

View file

@ -0,0 +1,28 @@
import { Type } from "./Type"
export class FunctionType implements Type {
public kind = "function"
constructor(public args: Type[], public ret: Type, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new FunctionType([` +
// Convert every argument type to its string constructor representation
this.args.map(cur => cur.toConstructor()) +
`], ${this.ret.toConstructor()}, ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `(${this.args.map(a => a.toString()).join(", ")}) => ${this.ret.toString()}`
}
public convert(argument) {
// Possible strategies:
// - eval()
// - window[argument]
// - tri.excmds[argument]
throw new Error(`Conversion to function not implemented: ${argument}`)
}
}

View file

@ -0,0 +1,26 @@
import { Type } from "./Type"
export class LiteralTypeType implements Type {
public kind = "LiteralType"
constructor(public value: string, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new LiteralTypeType(${JSON.stringify(this.value)}, ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return JSON.stringify(this.value)
}
public convert(argument) {
if (argument === this.value) {
return argument
}
throw new Error(
`Argument does not match expected value (${
this.value
}): ${argument}`,
)
}
}

View file

@ -0,0 +1,23 @@
import { Type } from "./Type"
export class NumberType implements Type {
public kind = "number"
public constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new NumberType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
const n = parseFloat(argument)
if (!Number.isNaN(n)) {
return n
}
throw new Error(`Can't convert to number: ${argument}`)
}
}

View file

@ -0,0 +1,43 @@
import { Type } from "./Type"
export class ObjectType implements Type {
public kind = "object"
// Note: a map that has an empty key ("") uses the corresponding type as default type
constructor(public members: Map<string, Type> = new Map<string, Type>(), public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new ObjectType(new Map<string, Type>([` +
Array.from(this.members.entries()).map(([n, m]) => `[${JSON.stringify(n)}, ${m.toConstructor()}]`)
.join(", ") +
`]), ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convertMember(memberName: string[], memberValue: string) {
let type = this.members.get(memberName[0])
if (!type) {
// No type, try to get the default type
type = this.members.get("")
if (!type) {
// No info for this member and no default type, anything goes
return memberValue
}
}
if (type.kind === "object") {
return (type as ObjectType).convertMember(memberName.slice(1), memberValue)
}
return type.convert(memberValue)
}
public convert(argument) {
try {
return JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert to object: ${argument}`)
}
}
}

View file

@ -0,0 +1,22 @@
import { Type } from "./Type"
export class StringType implements Type {
public kind = "string"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new StringType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
if (typeof argument === "string") {
return argument
}
throw new Error(`Can't convert to string: ${argument}`)
}
}

View file

@ -0,0 +1,39 @@
import { Type } from "./Type"
export class TupleType implements Type {
public kind = "tuple"
constructor(public elemTypes: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new TupleType([` +
// Convert every element type to its constructor representation
this.elemTypes.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `[${this.elemTypes.map(e => e.toString()).join(", ")}]`
}
public convert(argument) {
if (!Array.isArray(argument)) {
try {
argument = JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert to tuple: ${argument}`)
}
if (!Array.isArray(argument)) {
throw new Error(`Can't convert to tuple: ${argument}`)
}
}
if (argument.length !== this.elemTypes.length) {
throw new Error(
`Error converting tuple: number of elements and type mismatch ${argument}`,
)
}
return argument.map((v, i) => this.elemTypes[i].convert(v))
}
}

12
compiler/types/Type.ts Normal file
View file

@ -0,0 +1,12 @@
export interface Type {
// Only available on argument types
name?: string
isDotDotDot?: boolean
isQuestion?: boolean
// available everywhere
kind: string
toConstructor(): string
toString(): string
convert(argument: string): any
}

View file

@ -0,0 +1,22 @@
import { Type } from "./Type"
export class TypeReferenceType implements Type {
public constructor(public kind: string, public args: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new TypeReferenceType(${JSON.stringify(this.kind)}, [` +
// Turn every type argument into its constructor representation
this.args.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `${this.kind}<${this.args.map(a => a.toString()).join(", ")}>`
}
public convert(argument) {
throw new Error("Conversion of simple type references not implemented.")
}
}

View file

@ -0,0 +1,29 @@
import { Type } from "./Type"
export class UnionType implements Type {
public kind = "union"
constructor(public types: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new UnionType([` +
// Convert every type to its string constructor representation
this.types.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return this.types.map(t => t.toString()).join(" | ")
}
public convert(argument) {
for (const t of this.types) {
try {
return t.convert(argument)
} catch (e) {}
}
throw new Error(`Can't convert "${argument}" to any of: ${this.types}`)
}
}

View file

@ -0,0 +1,19 @@
import { Type } from "./Type"
export class VoidType implements Type {
public kind = "void"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new VoidType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
return null
}
}

View file

@ -1,3 +0,0 @@
# Tridactyl `contrib`
Not sure what we'll put in this directory, but it won't be as polished as the rest of Tridactyl.

View file

@ -1,112 +0,0 @@
/*
* Sidescrolling newspaper-style layout for :reader
*
* Author: bovine3dom
*
* Usage: `:colourscheme --module=reader --url=[path to raw version of this file] newspaper`
*
*/
:root {
--serif-font: EconomistSerif, Plantin, Garamond, serif;
}
html:not(.TridactylCommandline) {
height: 100vh;
width: 100vw;
overflow: hidden;
font-family: var(--serif-font);
}
:not(.TridactylCommandline) {
body {
display: block;
height: 100%;
width: 100%;
padding: 2rem;
column-width: 24rem;
column-gap: 3rem;
column-fill: auto;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: auto;
}
body > main {
display: contents;
}
body > header,
body > footer,
figure, section, article, .notice,
h1, h2, h3, h4, h5, h6,
img, video, table {
break-inside: avoid;
}
h1, h2, h3, h4, h5, h6 { break-after: avoid-column; }
h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + * { break-before: avoid-column; }
img, video, table, pre, figure {
display: block;
scrollbar-width: thin;
}
img, video, table {
max-width: 100%;
}
table, pre, figure, img {
width: 100%;
max-height: calc(100vh - 6rem);
overflow-y: auto;
}
body > header {
display: block;
width: 100%;
border-bottom: 2px solid var(--accent);
margin-bottom: 2rem;
}
body > footer {
display: block;
margin-top: 4rem;
border-top: 2px solid var(--accent);
background-color: var(--accent-bg);
}
body > header h1,
body > header p {
margin: 0.5rem auto;
}
body > header h1 { max-width: 100%; }
h1 { font-size: 2rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.7rem; }
h2, h3 { margin-top: 0.5rem; }
@media only screen and (max-width: 600px) {
html, body {
height: auto;
display: block;
overflow-x: hidden;
overflow-y: auto;
columns: 1 auto;
padding: 0;
}
body > main {
display: block;
padding: 1rem;
}
body > header {
margin-bottom: 0;
}
}
}

View file

@ -22,7 +22,7 @@ Tridactyl is very lucky to have a wide base of contributors, 30 at the time of w
- You could work on some feature that you really want to see in Tridactyl that we haven't even thought of yet.
- Our build process is a bit convoluted, but [excmds.ts][excmds] is probably where you want to start. Most of the business happens there.
- We use TypeDoc to produce the `:help` page. Look at the other functions in [excmds.ts][excmds] to get an idea of how to use it; if your function is not supposed to called from the command line, then please add `/** @hidden */` above it to prevent it being shown on the help page.
- Email addresses mentioned in commits must be monitored. Do not use `noreply` addresses. If you have a good reason why you cannot provide a monitored email address, you can ask a maintainer to adopt your commits under their email address.
- Our pre-commit hook runs prettier to format your code. Please don't circumvent it.
If you are making a substantial or potentially controversial change, your first port of call should be to stop by and chat to us on [Matrix][matrix] or file an issue to discuss what you would like to change. We really don't want you to waste time on a pull request (GitHub jargon for a contribution) that has no chance of being merged; that said, we are probably happy to gate even the most controversial changes behind an option.

View file

@ -1,90 +0,0 @@
const bcd = require("@mdn/browser-compat-data")
const api = bcd.webextensions.api
function propertyNameOrValue(n) {
return n.property.type == "Literal" ? n.property.value : n.property.name
}
function isVersionNewer(version, minimumVersion) {
const versionParts = String(version).split(".").map(Number)
const minimumParts = String(minimumVersion).split(".").map(Number)
if (versionParts.some(Number.isNaN) || minimumParts.some(Number.isNaN))
return false
const length = Math.max(versionParts.length, minimumParts.length)
for (let i = 0; i < length; i++) {
const difference = (versionParts[i] || 0) - (minimumParts[i] || 0)
if (difference !== 0) return difference > 0
}
return false
}
function detectBrowserUsage(context, node, browser, minimumVersion) {
let localApi = api
const fullName = []
while (
node.type == "MemberExpression" &&
propertyNameOrValue(node) in localApi
) {
const n = node
node = node.parent
const name = propertyNameOrValue(n)
fullName.push(name)
localApi = localApi[name]
if (!localApi.__compat) {
continue
}
const support = localApi.__compat.support
if (support[browser].version_added === false) {
context.report({
node: n,
messageId: "unsupportedApis",
data: {
name: browser,
api: fullName.join("."),
},
})
} else {
const version = support[browser].version_added
if (
minimumVersion !== undefined &&
isVersionNewer(version, minimumVersion)
) {
context.report({
node: n,
messageId: "apiTooRecent",
data: {
api: fullName.join("."),
name: browser,
version: minimumVersion,
},
})
}
}
}
}
module.exports = browser => ({
meta: {
schema: [
{
type: "object",
properties: { minimumVersion: { type: "string" } },
additionalProperties: false,
},
],
messages: {
unsupportedApis: "{{ api }} unsupported on '{{ name }}'",
apiTooRecent:
"{{ api }} is not supported on {{ name }} {{ version }}",
},
},
create(context) {
const { minimumVersion } = context.options[0] || {}
const detect = node =>
detectBrowserUsage(context, node, browser, minimumVersion)
return {
'MemberExpression[object.name="browser"]': detect,
'MemberExpression[object.name="browserBg"]': detect,
}
},
})

View file

@ -1,24 +0,0 @@
# adding a new target
make a file here like
```js
// unsupported-apis-mosaic.js
module.exports = require("./lib/unsupported-apis")("mosaic")
```
add a line to `../browser-targets.json`
```json
"mosaic": { "minimumVersion": "1.0.0" }
```
add a few lines to `../.eslintrc.js`
```js
"unsupported-apis-mosaic": [
"warn",
{ "minimumVersion": browserTargets.mosaic.minimumVersion }
],
```

View file

@ -1 +0,0 @@
module.exports = require("./lib/unsupported-apis")("chrome")

View file

@ -1 +0,0 @@
module.exports = require("./lib/unsupported-apis")("firefox")

View file

@ -0,0 +1,63 @@
const bcd = require('@mdn/browser-compat-data');
const api = bcd.webextensions.api;
const supported_browsers = ["firefox", "chrome"];
const minimalSupportedFirefoxVersion = 114;
function propertyNameOrValue(n) {
return (n.property.type == "Literal" ? n.property.value : n.property.name)
}
function detectBrowserUsage(context, node) {
let localApi = api;
let fullName = [];
while (node.type == "MemberExpression" && propertyNameOrValue(node) in localApi) {
const n = node;
node = node.parent;
let name = propertyNameOrValue(n);
fullName.push(name);
localApi = localApi[name];
if (!localApi.__compat) {
continue;
}
let support = localApi.__compat.support;
for (let browser of supported_browsers) {
if (support[browser].version_added === false) {
context.report({
node: n,
messageId: "unsupportedApis",
data: {
name: browser,
api: fullName.join("."),
}
});
} else {
const version = Number(support[browser].version_added);
if (!isNaN(version) && version > minimalSupportedFirefoxVersion) {
context.report({
node: n,
messageId: "apiTooRecent",
data: {
api: fullName.join("."),
version: minimalSupportedFirefoxVersion
}
});
}
}
}
}
}
module.exports = {
meta: {
messages: {
unsupportedApis: "{{ api }} unsupported on '{{ name }}'",
apiTooRecent: "{{ api }} is not supported on firefox {{ version }}",
}
},
create(context) {
return {
'MemberExpression[object.name="browser"]': (n) => detectBrowserUsage(context, n),
'MemberExpression[object.name="browserBg"]': (n) => detectBrowserUsage(context, n),
};
}
};

View file

@ -1,6 +1,6 @@
**Control your browser with your keyboard _only_.**
Replace Firefox's control mechanism with one modelled on VIM. We were heavily inspired by VimFX, Vimperator and Pentadactyl, but more modern equivalents are Vimium or qutebrowser. Most common tasks you want your browser to perform are bound to a single key press:
Replace Firefox's control mechanism with one modelled on VIM. This is a "Firefox Quantum" replacement for VimFX, Vimperator and Pentadactyl. Most common tasks you want your browser to perform are bound to a single key press:
- You want to open a new tab? Hit `t`.
- You want to follow that link? Hit `f` and type the displayed label. (Note: hint characters should be typed in lowercase.)
@ -52,16 +52,14 @@ Since Tridactyl aims to provide all the features Vimperator and Pentadactyl had,
- Access recently closed tabs:
- If you've accidentally closed a tab or window, Tridactyl will let you open it again with the `:undo` command which is bound to `u` by default.
- Access browser tabs:
- Tridactyl provides a quick tab-switching menu/command with the `:tab` command (bound to `b`). This permission is also required to close, move, and pin tabs, amongst other things.
- Tridactyl provides a quick tab-switching menu/command with the `:buffer` command (bound to `b`). This permission is also required to close, move, and pin tabs, amongst other things.
- Access browser activity during navigation:
- This is needed for Tridactyl to be able to go back to normal mode every time you open a new page. It is also used for autocommands.
- This is needed for Tridactyl to be able to go back to normal mode every time you open a new page. In the future we may use it for autocommands.
- Read the text of all open tabs:
- This allows us to use Firefox's built-in find-in-page API, for, for example, allowing you to bind find-next and find-previous to `n` and `N`.
- Monitor extension usage and manage themes:
- Tridactyl needs this to integrate with and avoid conflicts with other extensions. For example, Tridactyl's contextual identity features use this to cooperate with the Multi-Account Containers extension.
- Hide and show browser tabs:
- Hide tabs:
- Tridactyl needs this for tab group commands, which allow associating names with different groups of tabs and showing the tabs from only of those groups at a time.
- Control browser proxy settings:
- This allows you to set proxies for accessing different websites with :autocontain.
[betas]: https://tridactyl.cmcaine.co.uk/betas/?sort=time&order=desc

View file

@ -1,44 +0,0 @@
{"word": "mozilla", "definition": "The foundation that controls Firefox and Thunderbird."}
{"word": "bovine3dom", "definition": "The esteemed maintainer of Tridactyl and author of this glossary."}
{"word": "vimperator", "definition": "A now defunct Firefox extension that was the main inspiration for Tridactyl."}
{"word": "commandline", "definition": "A text-based interface to Tridactyl for running commands interactively and selecting from completion options, also called ex-mode. By default, bound to `:`"}
{"word": "completion", "definition": "An object that can be selected from the commandline to complete a partially typed command, such as a URL for `:open` or a tab for `:tab`"}
{"word": "keybind", "definition": "A relationship between a key press or series of key presses and a command"}
{"word": "keysequence", "definition": "A series of key presses or key combinations that are descibed using a special language where, e.g. `<C-j>` represents `ctrl + j`"}
{"word": "keydown", "definition": "An event triggered when a key is first pressed, represented in keysequeences as `<D-[character]>`"}
{"word": "keyup", "definition": "The event triggered when a key is released, represented in keysequences as `<U-[character]>`"}
{"word": "keypress", "definition": "An event triggered when a key is pressed and periodically while it is held down, represented in keysequences as `[character]`"}
{"word": "pentadactyl", "definition": "A now defunct fork of Vimperator and one of the key inspirations for Tridactyl."}
{"word": "treestyletabs", "definition": "A popular Firefox extension that adds a vertical tree of tabs to the user interface"}
{"word": "tridactyl", "definition": "The best Firefox extension on the planet and the one you are using right now. Accept no imitations!"}
{"word": "userchrome", "definition": "A semi-secret file that Firefox reads, if the preferences are set correctly, and allows you to edit the GUI. Tridactyl has a helper function for this called `:guiset`"}
{"word": "tridactylrc", "definition": "A 'run commands' file that simply runs a series of commands at startup. Many users use these files to 'store' their settings, but it is important to remember that it really is just a bunch of commands to run at startup. See `:source`, `:mkt` and `:native`"}
{"word": "xclip", "definition": "A command line utility that allows you to manipulate the clipboard under Linux and X11. See `:help yankto` and `:help putfrom`"}
{"word": "xsel", "definition": "A command line utility that allows you to manipulate the clipboard under Linux and X11. See `:help yankto` and `:help putfrom`"}
{"word": "sandboxed", "definition": "A security feature that prevents processes such as Firefox from accessing the file system, which breaks most of `:native` without some extra fiddling and whitelisting. 'snap' and 'flatpak' are common forms of sandboxing."}
{"word": "github", "definition": "A popular code hosting site (aka 'forge') that allows you to store, share and discuss code with other users. At the time of writing, it's where Tridactyl was mostly hosted, although it's important to remember that `git` is decentralised and so really Tridactyl is hosted by developer who has ever cloned it"}
{"word": "mappings", "definition": "Another word for keybinds"}
{"word": "rebind", "definition": "To change the keybind a command is associated with"}
{"word": "gitter", "definition": "A now mostly dead chat service that has been replaced by Matrix, but still works"}
{"word": "deprecated", "definition": "A command or setting that will eventually be removed from Tridactyl, but works for now"}
{"word": "emacs", "definition": "An operating system that only lacks a decent text editor. Inexplicably popular despite the fact that `nano` and `notepad.exe` are superior and pre-installed on most machines"}
{"word": "popup", "definition": "A small window that appears in the middle of the screen, usually without consent to display something that the website wishes to annoy you with. Thankfully, web browsers block them universally, and so now they appear within webpages instead in the form of newsletter signups and 'cookie consent' dialogs, which is vastly better than having easily detectable new windows created because ... there's got to be a reason, right? Anyway, don't use the internet without an adblocker like uBlock Origin installed, kids"}
{"word": "javascript", "definition": "Brendan Eich's long-running joke played on humanity. Tragically, it is also the language in which we are forced to write Tridactyl."}
{"word": "firefox", "definition": "The web browser you're almost certainly using right now"}
{"word": "vim", "definition": "The best text editor in the universe, second only to 'ex'"}
{"word": "libera", "definition": "An IRC network that became popular after Freenode imploded. Come talk with us on #tridactyl like it's 2002"}
{"word": "wiki", "definition": "Tridactyl's largely unmoderated and well hidden wiki, stored on GitHub"}
{"word": "layout", "definition": "Which button inserts which character on a keyboard. You can change this in software."}
{"word": "rc", "definition": "Shorthand for 'tridactylrc'"}
{"word": "sidebar", "definition": "Part of the Firefox UI that appears on the left and extensions can put things in. We mostly use it for `:escapehatch` to get focus back onto the page, but `:sidebaropen` exists and has some enthusiastic users"}
{"word": "addon", "definition": "A Firefox extension such as Tridactyl"}
{"word": "matrix", "definition": "A chat network"}
{"word": "pipe", "definition": "A way of sending data between commands, usually represented by `|`"}
{"word": "messenger", "definition": "A small program that runs in userland and makes `:native` work for various tasks such as opening URLs Mozilla thinks are dangerous, starting system utilities like `xclip` or restarting the browser"}
{"word": "ex", "definition": "The original line-based text editor upon which Vim was initially based. The commandline is called 'ex mode' in honour of this, commands are called 'ex commands' and we sometimes call series of ex commands 'ex scripts'"}
{"word": "marks", "definition": "A way of remembering a position or URL"}
{"word": "issue", "definition": "A bug report, feature request or question on GitHub. We often talk about 'filing' issues to mean creating them"}
{"word": "recently", "definition": "Anything within the last decade"}
{"word": "control", "definition": "The key marked 'ctrl' on your keyboard. Unlike many other programs, we really do mean the key marked 'ctrl', and not the splat key on MacOS, which we call 'meta'"}
{"word": "jumplist", "definition": "A kind of history of bits of pages you have been on, accessible with `<C-o>` and `<C-i>`, and `g;` for text fields you have edited"}
{"word": "sponsor", "definition": "Someone who gives money to bovine3dom so he can keep working on Tridactyl and spend his time writing definitions for the glossary like he is doing right now"}

View file

@ -1,55 +0,0 @@
# Tridactyl news - Winter 2024
Hello,
Welcome to the tenth Tridactyl newsletter! It looks like I've fallen into a routine of doing them biennially; I'll try to make them at least once a year because we're adding stuff to Tridactyl at such a rate that it becomes a little difficult to summarise it all. On top of this, I wrote this newsletter in around December 2024 but somehow managed to forget to release it, although it was on a public git branch. But everything in it is still relevant, so I am publishing it now in December 2025. And I will aim to write an actually new newsletter in January/February. Thanks as ever for your patience :)
What follows is a brief exploration of the changes in Tridactyl since the last newsletter came out in February 2023.
## Highlighted new features
I'll go through a few of my favourite new features, ones that I myself am using daily. The first is that we added a new `:reader` mode, left unbound by default because while we take every precaution, running it on untrusted websites opens up a larger attack surface than using the built-in Firefox reader (which remains accessible on the `gr` bind and with the `:reader --old` command). You can of course bind to the new mode with `:bind gr reader`, which is what I personally do. This lets you use all of the Tridactyl commands and interface on a minimal "reader" style interface and seems in general to work on slightly more websites than the built-in mode. I use it on websites that go slightly overboard with the cookie warnings, modal newsletter pop-ups and banner adverts ... that is to say, most of them :)
A totally different but equally transformative feature we've added is the ability to sort tabs everywhere in Tridactyl in a "most recently used" fashion. By running `:set tabsort mru`, not only will `b`/`:tab` completions be sorted by recency, but within Tridactyl internally all the commands will use recency indices too, meaning that for example `[n]gt` will take you to the nth-most-recently-used tab rather than the tab with index n. I found it took about a week to get used to but now I can't imagine using Firefox and Tridactyl in any other way. It makes doing housekeeping on tabs easy too, because one can just press shift-tab to get to the end of the `:tab` list in recency to find the ones that haven't been used in a while, and then close them one by one with shift-delete.
For something a little more niche, Tridactyl's support for non-English keyboard layouts has got a little better with the addition of `:set keyboardlayoutforce true`. This forces Tridactyl to see your keyboard as if it was a different layout - by default, US, but configurable via `:set keyboardlayoutbase`. This means that for example, pressing the home row key for the right hand will always be interpreted as j, which is bound by default to scrolling down. If you're frequently switching between keyboard layouts so you can easily type in different languages (for example, colemak and bépo), your Tridactyl binds will stay in the same physical location.
A cool command that I've been using a lot personally and will be of interest to anyone who uses a mobile device is the new `yq` bind, which temporarily displays the current URL as a QR code. There's also a `q` bind in visual mode and a `:text2qr` command for general programmatic use.
A small quality of life improvement is that `:open` and related commands now have completions for searchurls and previous searches.
A few improvements have come to hints: there's now `:set hintstyles.{bg, fg, outline}` for changing the style of hints without changing your theme, `;c` and `;:` for context menu / mouse hover binds added for `:native` and `xdotool` users. There are also new flags: `-x` for excluding CSS selectors and `-C` for including extra CSS selectors alongside the defaults.
With the command line open, `<C-o>t` opens a new tab in the background for the selected completion which I find especially useful with `:back` completions. Don't forget that all binds for the command line can be viewed with `:viewconfig exmaps` and in `:bind --mode=ex ` completions - I think most people are unaware of them, which is a shame because `<S-Delete>` to close `:tab ` completions is one of my favourite Tridactyl features.
A pretty odd feature that I am not totally unconvinced of the utility of is that we now have experimental support for opening arbitrary pages in the sidebar. See `:help sidebartoggle` and `:help sidebaropen` for more details; it works somewhat well with `:hint -W sidebaropen` but again I'm still not sure what it's useful for. Feel free to tell us if you do find it useful.
An extremely niche feature is the new `:jsua` command which preserves "user action" intent when using browser binds, needed for triggering certain Web Extension APIs such as `browser.sidebarAction.open()`. I suspect there are about three people in the world who care about this :). We've also added `tri.bg` and `tri.tabs` for easy communication between tab and background contexts in `:jsb` and `:js`, see their help pages for more information.
Something that I suspect will interest people who don't read this newsletter more than people who do is that we've started the groundwork for multi-browser support in our linting scripts. We haven't considered how to go about Manifest V3 support at all, so support for Chromium would still be a long way off, even if we wanted to. But it's nice that the option to slowly port to Chromium-based browsers who maintain support for Manifest V2 is there.
For those of you who want to talk about Tridactyl to other people, you'll be happy to know that docs are now built and hosted [on our website](https://tridactyl.xyz/build/static/docs/modules/_src_excmds_.html). I find I use it a lot when helping people on Discord/IRC/Matrix, maybe you will too.
## Neat bug fixes
We've finally fixed a feature that had been broken in Tridactyl for years - the grey hints for elements that have Javascript event handlers associated with them now work again. On some sites, this means that there are lots of duplicated hints - just run `:hint -J` (with perhaps `:bindurl ... f hint -J`) to exclude them from the displayed hints.
`:guiset gui none` works again in Firefox 133 - you may find that you need to rerun it.
In an apparent attempt to make me hate React even more than I already did, server-side rendering on React and Next.js started vandalising Tridactyl. I had to write a workaround so that Tridactyl reinserts itself when it detects that server-side React is running. Our detection isn't foolproof so may need to use `:seturl ... commandlineterriblewebsitefix true` to force Tridactyl to reinsert itself proactively. Since a fair number of the readers of this newsletter are probably web-developers, this might be a good time to remind you all that [friends don't let friends use React](https://infrequently.org/2024/11/if-not-react-then-what/). There are plenty of modern approaches to web development that don't spend their time reimplementing basic browser features, like Svelte or Lit. I have to say that my opinions on this hardened slightly after I was forced to write some code to deal with React tearing up the whole web page because the user agent dared to make some modifications to it.
This one probably only affects a small number of you, but `:apropos` will now work even if you have created a custom setting, which is important, because it's a great command for exploring Tridactyl :)
A bug that had been annoying me for most of the life of Tridactyl is that keypresses typed too quickly after a command opened the commandline would be lost. We now buffers character keypresses which has improved this a lot (although we can still lose some keys before the buffer starts collecting them, depending on how quick your computer is).
Most `searchurls` have been removed because people (especially me) found them annoying, you can add them back with `:set searchurls.[name] [url]` if there are any that you find yourself missing.
Finally, and most importantly, I added my [mastodon account](https://masto.ai/@bovine3dom) to the new tab page so that people can listen to me complain about about trains.
## Plans for next few months (years?)
I don't have anything terribly exciting planned for Tridactyl at the moment - we've got 82 pull requests open at the time of writing which is about 70 more than I would like, so I think I'll start trying to go through them and see what can be merged or freshened up and what should be closed. It's been a long time since I picked issues out at random to fix them so I'll probably set aside a weekend or two for that soon. At the back of my mind, I always think about rewriting our completion/commandline to be more deterministic and more configurable but the trouble is that it kind of works OK so it doesn't seem worth the time required to get it going. I would also like to fix up the ["keyup" bind PR](https://github.com/tridactyl/tridactyl/pull/4727) since that would enable real smooth scrolling which seems to be one of the main barriers to people using Tridactyl, but keybinds are such a key part of Tridactyl that the idea of touching that code and breaking it slightly is honestly a little bit terrifying. And then finally I still have [godmode](https://github.com/tridactyl/godmode) on my wishlist, which should get Tridactyl running on PDF and other pages like Firefox's own extensions such as the screenshot tool, for people who are prepared to patch Firefox.
As always, thanks for your generous support,
bovine3dom and the rest of the Tridactyl developers

View file

@ -8,22 +8,10 @@ If changing one of these settings fixes your bug, please visit the corresponding
- `:seturl $URL_OF_THE_WEBSITE superignore true` and then reload the page. This totally disables Tridactyl from loading on the page. No specific issue, please make a new one: https://github.com/tridactyl/tridactyl/issues/821
- "i can't open the commandline :((" `:seturl [the website you're on] commmandlineterriblewebsitefix true`. No need to file an issue with us, but maybe consider telling the website owner that they should [make their website less bad](https://infrequently.org/2024/11/if-not-react-then-what/). Our relevant issue is [#5050](https://github.com/tridactyl/tridactyl/issues/5050)
# Firefox settings that can break Tridactyl
If you have `privacy.resistFingerprinting` set to `true` in `about:config`, Tridactyl will have a lot of trouble understanding your keypresses. See [#760](https://github.com/tridactyl/tridactyl/issues/760#issuecomment-433679201) and [#1699](https://github.com/tridactyl/tridactyl/issues/1699). We strongly recommend setting it to `false`, as it is by default.
Selecting **Never remember history** in the History section of Firefox's Privacy & Security settings enables permanent private browsing. Tridactyl then treats every window as private and cannot persist state such as command history or global marks. Choose another history setting unless this behaviour is intended.
# Keyboard layout issues
Tridactyl's completion-aware `<Space>` binding can interfere with dead-key composition in the command line. Run `:unbind --mode=ex <Space>` to let Firefox handle Space normally, then `:bind --mode=ex <S-Space> ex.insert_character_or_completion` to move completion to Shift-Space. [#5061](https://github.com/tridactyl/tridactyl/issues/5061)
# RC file issues
If `:source` loads unexpected settings or does not reflect your changes, run `:findrc` to display the local RC file selected when no path is supplied. Check that path for an old or duplicate RC file.
# Native Editor/Messenger issues
If you're having trouble running your editor on OSX, you might be having \$PATH issues: [#684](https://github.com/tridactyl/tridactyl/issues/684). The solution is to specify the absolute path to your editor, like this: `:set editorcmd /usr/local/bin/vimr`.

View file

@ -8,7 +8,6 @@ import {
getDriver,
getDriverAndProfileDirs,
iframeLoaded,
quitDrivers,
sendKeys,
} from "./utils"
@ -23,7 +22,9 @@ describe("webdriver", () => {
driver = await getDriver()
})
afterEach(quitDrivers)
afterEach(async () => {
await driver.quit()
})
interface Tab {
active: boolean
@ -123,14 +124,11 @@ describe("webdriver", () => {
"elem.innerText=`%u`;" +
"document.body.appendChild(elem)<CR>",
)
await driver.executeScript(`
["news/rss.xml", "views/atom.xml", "pews/rss.xml", "tews/atom.xml"].forEach(href => {
const link = document.createElement("a")
link.href = href
document.body.appendChild(link)
})`)
// First, make sure completions are offered
await driver.get(
"file:///" + process.cwd() + "/e2e_tests/html/rss.html",
)
const iframe = await iframeLoaded(driver)
await sendKeys(driver, ":rssexec ")
await driver.switchTo().frame(iframe)
@ -198,8 +196,8 @@ describe("webdriver", () => {
const { driver, newProfiles } = await getDriverAndProfileDirs()
try {
// Then, make sure `:guiset` is offering completions
await sendKeys(driver, ":guiset ")
const iframe = await iframeLoaded(driver)
await sendKeys(driver, ":guiset ")
await driver.switchTo().frame(iframe)
const elements = await driver.findElements(
By.className("GuisetCompletionOption"),
@ -214,7 +212,7 @@ describe("webdriver", () => {
`return document.getElementById("tridactyl-input").value`,
),
).toEqual(
`userChrome.css written to ${newProfiles[0]}/chrome/userChrome.css. Please restart Firefox to see the changes.`,
"userChrome.css written. Please restart Firefox to see the changes.",
)
const profile = newProfiles.find(async p =>
(await fs.readdir(path.join(p, "chrome"))).find(files =>
@ -224,6 +222,8 @@ describe("webdriver", () => {
expect(profile).toBeDefined()
} catch (e) {
fail(e)
} finally {
await driver.quit()
}
})
@ -368,7 +368,7 @@ describe("webdriver", () => {
await untilTabUrlMatches(
driver,
newTab.id,
new RegExp("^https?:\/\/.*qwant", "i"),
new RegExp("^https://www.google.com/search.*qwant"),
)
} catch (e) {
fail(e)

12
e2e_tests/html/rss.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tridactyl RSS test page</title>
</head>
<body>
<a href="news/rss.xml">
<a href="views/atom.xml">
<a href="pews/rss.xml">
<a href="tews/atom.xml">
</body>
</html>

View file

@ -3,10 +3,9 @@ import * as path from "path"
import * as os from "os"
import * as process from "process"
import { Browser, Builder, By, Key, WebDriver } from "selenium-webdriver"
import { Driver, Options, ServiceBuilder } from "selenium-webdriver/firefox"
import { Driver, Options } from "selenium-webdriver/firefox"
import * as Until from "selenium-webdriver/lib/until"
const env = process.env
const drivers = new Set<Driver>()
/** Returns the path of the newest file in directory */
export async function getNewestFileIn(directory: string): Promise<string> {
@ -42,41 +41,26 @@ export async function getDriver() {
)
const options = new Options()
if (!env["HEADED"]) {
if (env["HEADLESS"]) {
options.addArguments("--headless")
}
const driver = new Builder()
.forBrowser(Browser.FIREFOX)
.setFirefoxOptions(options)
// Required to evaluate scripts in extension pages; only for test browsers.
.setFirefoxService(new ServiceBuilder().addArguments("--allow-system-access"))
.build() as unknown as Driver
drivers.add(driver)
// This will be the default tab.
await driver.installAddon(extensionPath, true)
// Wait for multiple window handles to be available (extension may open in new tab)
await driver.wait(async () => {
const handles = await driver.getAllWindowHandles()
return handles.length >= 2
}, 10000)
// Give the extension a bit more time to initialize
// Wait until addon is loaded and :tutor is displayed
await iframeLoaded(driver)
const handles = await driver.getAllWindowHandles()
// And wait a bit more otherwise Tridactyl won't be happy
await driver.sleep(500)
let handles = await driver.getAllWindowHandles()
// Handle edge case where extension loads in same tab (some headless configurations)
if (handles.length === 1) {
await driver.wait(async () => {
const newHandles = await driver.getAllWindowHandles()
return newHandles.length >= 2
}, 10000)
handles = await driver.getAllWindowHandles()
}
// Kill the original tab.
await driver.switchTo().window(handles[0])
await driver.close()
// Switch to the new tab (extension opens in new tab)
// Switch back to the good tab.
await driver.switchTo().window(handles[1])
await driver.wait(() => driver.executeScript<boolean>("return Boolean(window.tri)"), 10000)
// Now return the window that we want to use.
return driver
}
@ -99,18 +83,6 @@ export async function getDriverAndProfileDirs() {
return { driver, newProfiles }
}
export async function quitDrivers() {
const results = await Promise.allSettled(
[...drivers].map(driver =>
driver.quit().finally(() => drivers.delete(driver)),
),
)
const failure = results.find(result => result.status === "rejected")
if (failure?.status === "rejected") {
throw failure.reason
}
}
const vimToSelenium = {
Down: Key.ARROW_DOWN,
Left: Key.ARROW_LEFT,

View file

@ -1,111 +0,0 @@
{
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
"version": "v1.1.0",
"entity": {
"type": "individual",
"role": "owner",
"name": "Oliver Blanthorn",
"email": "freedom4cows@gmail.com",
"description": "Creator and maintainer of Tridactyl, a popular vim-like web extension for Firefox, and various open-data/geospatial projects.",
"webpageUrl": {
"url": "https://github.com/bovine3dom"
}
},
"projects": [
{
"guid": "tridactyl",
"name": "Tridactyl",
"description": "A popular vim-like web extension for Firefox, spiritual successor to Vimperator/Pentadactyl",
"webpageUrl": {
"url": "https://github.com/tridactyl/tridactyl"
},
"repositoryUrl": {
"url": "https://github.com/tridactyl/tridactyl"
},
"licenses": ["spdx:Apache-2.0"],
"tags": ["browser-extensions", "developer-tools"]
}
],
"funding": {
"channels": [
{
"guid": "github",
"type": "payment-provider",
"address": "https://github.com/bovine3dom/sponsors",
"description": "For most people, the most efficient way of funding me - Microsoft covers all card/exchange fees"
},
{
"guid": "liberapay",
"type": "payment-provider",
"address": "https://liberapay.com/bovine3dom"
},
{
"guid": "bank",
"type": "cash",
"address": "freedom4cows@gmail.com",
"description": "Contact me directly for my IBAN/BIC"
},
{
"guid": "revolut",
"type": "payment-provider",
"address": "https://revolut.me/oab"
},
{
"guid": "crypto",
"type": "other",
"address": "freedom4cows@gmail.com",
"description": "Contact me directly for crypto donations because there are too many different coins to list :)"
},
{
"guid": "paypal",
"type": "payment-provider",
"address": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7JQHV4N2YZCTY",
"description": "NB: I only receive ~80% of the income after fees"
},
{
"guid": "patreon",
"type": "payment-provider",
"address": "https://patreon.com/tridactyl",
"description": "NB: I only receive ~60% of the income after fees and forced VAT"
}
],
"plans": [{
"guid": "feedme",
"status": "active",
"name": "Monthly donations",
"description": "Most of my living costs are monthly and so monthly donations massively help me to plan",
"amount": 0,
"currency": "EUR",
"frequency": "monthly",
"channels": ["github", "liberapay", "bank", "crypto", "revolut", "paypal", "patreon"]
}],
"history": [
{
"year": 2023,
"income": 4200,
"taxes": 1000,
"currency": "EUR",
"description": "Approximate"
},
{
"year": 2024,
"income": 6500,
"taxes": 1500,
"currency": "EUR",
"description": "Approximate"
},
{
"year": 2025,
"income": 5800,
"taxes": 1400,
"currency": "EUR",
"description": "Approximate"
}
]
}
}

View file

@ -14,15 +14,12 @@ source ./scripts/common.sh
jsfiles=$(cachedTSLintFiles)
otherfiles=$(cachedPrettierFiles)
# Exit early if no relevant files are staged
if [ -z "$jsfiles" ] && [ -z "$otherfiles" ]; then
exit 0
fi
echo "Running pre-commit hook..."
# Check if any of the files are ugly or contain a console.log call
consoleFiles=$(noisy $jsfiles)
uglyFiles="$(eslintUgly $jsfiles)"
if [ ! -n "$uglyFiles" ]; then
uglyFiles="$(prettierUgly $otherfiles)"
fi
if [ -n "$consoleFiles" ]; then
echo "Warning: adding console.log calls in ${consoleFiles[@]}"
@ -30,26 +27,10 @@ if [ -n "$consoleFiles" ]; then
echo
fi
# # disabled because it was too noisy
#
# if [ -n "$jsfiles" ]; then
# yarn git-format-staged \
# --formatter "yarn --silent prettier --stdin-filepath \"{}\"" \
# "*.ts" "*.tsx"
# fi
# if [ -n "$otherfiles" ]; then
# yarn git-format-staged \
# --formatter "yarn --silent prettier --stdin-filepath \"{}\"" \
# "*.md" "*.css"
# fi
if [ -n "$jsfiles" ]; then
echo "Linting staged files..."
if ! yarn eslint --rulesdir custom-eslint-rules --quiet $jsfiles; then
echo ""
echo -e "eslint failed. Please fix the errors above."
echo 'If you see this message repeatedly, skip the check with git commit -n'
exit 1
fi
if [ -n "$uglyFiles" ]; then
echo "Prettify your files first:"
echo 'yarn run pretty'
echo ''
echo 'If you see this message repeatedly, skip the check with git commit -n'
exit 1
fi

View file

@ -1,8 +1,7 @@
const tsConfig = require('./tsconfig');
module.exports = {
testEnvironment: "jsdom",
testRunner: "jest-jasmine2",
preset: "ts-jest",
setupFiles: [
"jest-webextension-mock"
],
@ -10,19 +9,16 @@ module.exports = {
"./e2e_tests/failfast.js"
],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: {
...tsConfig.compilerOptions,
globals: {
"ts-jest": {
tsConfig: {
...tsConfig.compilerOptions,
types: ["jest", "node", "@types/firefox-webext-browser"]
},
diagnostics: {
ignoreCodes: [151001]
}
}
]
},
diagnostics: {
ignoreCodes: [151001]
},
}
},
moduleNameMapper: {
"@src/(.*)": "<rootDir>/src/$1"

View file

@ -13,52 +13,52 @@
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"cleanslate": "git+https://github.com/tridactyl/cleanslate.git#master",
"cleanslate": "^0.10.1",
"compute-scroll-into-view": "^3.0.0",
"csp-serdes": "github:cmcaine/csp-serdes",
"css": "^3.0.0",
"editor-adapter": "git+https://github.com/tridactyl/editor-adapter.git#master",
"editor-adapter": "^0.0.5",
"esbuild": "^0.20.2",
"fuse.js": "^7.0.0",
"nearley": "^2.20.1",
"ramda": "^0.30.1",
"reading-time": "^1.5.0",
"semver-compare": "^1.0.0",
"stream-browserify": "^3.0.0",
"tridactyl-arg": "git+https://github.com/GHolk/arg.git#v5.1.0",
"tsdef": "^0.0.14",
"typedoc": "0.28.20",
"typedoc": "^0.19.2",
"typedoc-default-themes": "^0.12.10",
"xss": "^1.0.15"
},
"devDependencies": {
"@types/css": "0.0.38",
"@types/css": "0.0.37",
"@types/firefox-webext-browser": "^120.0.4",
"@types/jest": "29.5.12",
"@types/jest": "^27.5.0",
"@types/nearley": "^2.11.5",
"@types/selenium-webdriver": "^4.1.10",
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"command-line-args": "^6.0.1",
"eslint": "8.57.1",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/eslint-plugin-tslint": "^6.21.0",
"@typescript-eslint/parser": "^4.33.0",
"command-line-args": "^5.2.1",
"eslint": "^7.32.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^50.6.1",
"eslint-plugin-jsdoc": "^48.2.1",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-sonarjs": "^0.25.1",
"geckodriver": "^5.0.0",
"git-format-staged": "^4.0.1",
"geckodriver": "^4.3.3",
"jasmine-fail-fast": "^2.0.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-jasmine2": "29.7.0",
"jest-webextension-mock": "^4.0.0",
"jest": "^25.5.4",
"jest-webextension-mock": "^3.9.0",
"marked": "^12.0.1",
"prettier": "^3.4.2",
"prettier": "^3.2.5",
"selenium-webdriver": "^4.7.1",
"ts-jest": "29.4.11",
"typescript": "6.0.3",
"web-ext": "^7.10.0",
"yaml-lint": "^1.7.0"
"ts-jest": "^25.5.1",
"tslint": "^5.20.1",
"tslint-etc": "^1.13.10",
"tslint-sonarts": "^1.9.0",
"typescript": "^3.9.10",
"web-ext": "^7.10.0"
},
"scripts": {
"build": "sh scripts/build.sh",
@ -70,14 +70,10 @@
"make-zip": "web-ext build --source-dir build --overwrite-dest",
"pretty": "bash scripts/pretty.sh",
"run": "web-ext run -s build/ -u 'paste.to'",
"runwithprofile": "node scripts/runwithprofile.js",
"test": "yarn run build && web-ext build --source-dir ./build --overwrite-dest && jest --silent",
"update-buildsystem": "rm -rf src/node_modules; yarn run clean",
"watch": "echo 'watch is broken, use build instead'; exit 0;",
"install": "git config core.hookspath hooks/",
"_dev": "yarn run rebuild && yarn run run",
"dev": "ls **/*.ts | entr -r yarn run _dev",
"githublint": "yamllint .github/workflows/**.yml"
"install": "git config core.hookspath hooks/"
},
"author": "Colin Caine",
"repository": {
@ -94,6 +90,5 @@
"bugs": {
"url": "https://github.com/tridactyl/tridactyl/issues"
},
"homepage": "https://github.com/tridactyl/tridactyl#readme",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"homepage": "https://github.com/tridactyl/tridactyl#readme"
}

View file

@ -9,6 +9,7 @@ Tridactyl
<h4 align="center">Replace Firefox's default control mechanism with one modelled on the one true editor, Vim.</h4>
<p align="center">
<a href="https://travis-ci.org/tridactyl/tridactyl"><img src="https://travis-ci.org/tridactyl/tridactyl.svg?branch=master" alt="Build Status"></a>
<a href="https://matrix.to/#/#tridactyl:matrix.org"><img src="https://img.shields.io/badge/matrix-join%20chat-green" alt="Matrix Chat"></a>
<a href="https://gitter.im/tridactyl/Lobby"><img src="https://badges.gitter.im/Join%20Chat.svg" alt="Join Gitter Chat"></a>
<a href="https://discord.gg/DWbNGTAvmh"><img src="https://img.shields.io/discord/854326924402622474?color=%235865F2&label=discord" alt="Join Discord Chat"></a>
@ -52,7 +53,7 @@ Tridactyl stable can be installed from the [Mozilla add-ons website (the AMO)][a
### Extra features through [Native Messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging)
If you want to use advanced features such as edit-in-Vim, you'll also need to install the native messenger or executable, instructions for which can be found by typing `:nativeinstall` and hitting enter once you are in Tridactyl. Arch users can install the [AUR package](https://aur.archlinux.org/packages/firefox-tridactyl-native/) `firefox-tridactyl-native` instead.
If you want to use advanced features such as edit-in-Vim, you'll also need to install the native messenger or executable, instructions for which can be found by typing `:installnative` and hitting enter once you are in Tridactyl. Arch users can install the [AUR package](https://aur.archlinux.org/packages/firefox-tridactyl-native/) `firefox-tridactyl-native` instead.
#### Containerized/sandboxed Firefox Installations
@ -80,7 +81,7 @@ The changelog for the stable versions can be found [here](https://github.com/tri
Type `:help` or press `<F1>` for online help once you're in, or `:tutor` for a friendly introduction. You might also find the [unofficial Tridactyl Memrise course](https://app.memrise.com/community/course/5995499/tridactyls-main-shortcuts/) (requires login) useful for memorising keybinds.
Remember that Tridactyl cannot run on any page on about:\*, data:\*, view-source:\* and file:\*. We're sorry about that :(
Remember that Tridactyl cannot run on any page on about:\*, data:\*, view-source:\* and file:\*. We're sorry about that and we're working with Firefox to improve this situation by removing restrictions on existing APIs and developing a new API.
If you're enjoying Tridactyl, or not, please leave a review on the [AMO](https://addons.mozilla.org/en-US/firefox/addon/tridactyl-vim/reviews/).
@ -110,11 +111,11 @@ You can try `:help key` to know more about `key`. If it is an existing binding,
- `gi` — scroll to and focus the last-used input on the page
- `r`/`R` — reload page or hard reload page
- `yy` — copy the current page URL to the clipboard
- `[[`/`]]` — navigate backward/forward though paginated pages, for example comics, multi-part articles, search result pages, etc.
- `[[`/`]]` — navigate forward/backward though paginated pages, for example comics, multi-part articles, search result pages, etc.
- `]c`/`[c` — increment/decrement the current URL by 1
- `gu` — go to the parent of the current URL
- `gU` — go to the root domain of the current URL
- `gr` — open Firefox reader mode (note: Tridactyl will only work on our own `:reader` command which has complicated security implications)
- `gr` — open Firefox reader mode (note: Tridactyl will not work in this mode)
- `zi`/`zo`/`zz` — zoom in/out/reset zoom
- `<C-f>`/`<C-b>` — jump to the next/previous part of the page
- `g?` — Apply Caesar cipher to page (run `g?` again to switch back)
@ -161,7 +162,7 @@ If you want to use Firefox's default `<C-b>` binding to open the bookmarks sideb
- `u` — undo the last tab/window closure
- `gt`/`gT` — go to the next/previous tab
- `g^ OR g0`/`g$` — go to the first/last tab
- `ga` — go to the tab currently playing audio, or the one that most recently stopped
- `ga` — go to the tab currently playing audio
- `<C-^>` — go to the last active tab
- `b` — bring up a list of open tabs in the current window; you can type the tab ID or part of the title or URL to choose a tab
@ -184,7 +185,7 @@ Additionally, you can hint elements matching a custom CSS selector with `:hint -
### Binding custom commands
You can bind your own shortcuts in normal mode with the `:bind` command. For example `:bind gD composite tabduplicate; tabdetach` to duplicate and detach the current tab. See `:help bind` for details about this command.
You can bind your own shortcuts in normal mode with the `:bind` command. For example `:bind J tabprev` to bind `J` to switch to the previous tab. See `:help bind` for details about this command.
## WebExtension-related issues
@ -209,10 +210,6 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
`:set searchengine esa`
Note that this does not apply to `:open` when run without arguments, as it simply opens the newtab page which can be set like this:
`:set newtab [newtab url]`, e.g. `:set newtab https://www.google.com`
- How can I add a search engine?
`:set searchurls.esa http://www.esa.int/esasearch?q=`
@ -225,7 +222,7 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
- Can I import/export settings, and does Tridactyl use an external configuration file just like Vimperator?
Yes. `:source --url [URL]` accepts a URL (which must contain only an RC file, e.g. `raw.githubusercontent.com/...`). If you have `native` working, `$XDG_CONFIG_HOME/tridactyl/tridactylrc` or `~/.tridactylrc` will be read at startup via an `autocmd` and `source`. Run `:findrc` to see which local RC file this automatic search selected. There is an [example file available on our repository](https://github.com/tridactyl/tridactyl/blob/master/.tridactylrc), or you can [search GitHub for other RC files](https://github.com/search?q=path%3Atridactylrc&type=code) (requires login).
Yes. `:source --url [URL]` accepts a URL (which must contain only an RC file, e.g. `raw.githubusercontent.com/...`). If you have `native` working, `$XDG_CONFIG_HOME/tridactyl/tridactylrc` or `~/.tridactylrc` will be read at startup via an `autocmd` and `source`. There is an [example file available on our repository](https://github.com/tridactyl/tridactyl/blob/master/.tridactylrc).
There's also `mkt` which exports your configuration to a file which may be read with `source`. (NB: this currently requires `native`).
@ -234,7 +231,6 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
To use one of the built in themes use: `:colors <color>`. The current options are:
- default
- auto
- dark (authored by @furgerf)
- shydactyl (authored by @atrnh)
- greenmat (authored by @caputchinefrobles)
@ -242,14 +238,12 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
- quake
- quakelight
- midnight (authored by @karizma)
- vimium
- tokyonight
Tridactyl can also load themes from disk or URL. You could use this for example to load one of the themes originally authored by @bezmi ([tridactyl/base16-tridactyl](https://github.com/tridactyl/base16-tridactyl)). See `:help colors` for more information.
- How to remap keybindings? or How can I bind keys using the control/alt key modifiers (eg: `ctrl+^`)?
You can remap keys with `:bind --mode=$mode $key $excmd`. See `:help bind` for more information.
You can remap keys in normal, ignore, input and insert mode with `:bind --mode=$mode $key $excmd`. Hint mode and the command line are currently special and can't be rebound. See `:help bind` for more information.
Modifiers can be bound like this: `:bind <C-f> scrollpage 1`. Special keys can be bound too: `:bind <F3> colors dark` and with modifiers: `:bind <S-F3> colors default` and with multiple modifiers: `:bind <SA-F3> composite set hintchars 1234567890 | set hintfiltermode vimperator-reflow`
@ -281,7 +275,7 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
- Can I change proxy via commands?
Yes, see `:help proxyadd`
Not yet, but this feature will eventually be implemented.
- How do I disable Tridactyl on certain sites?
@ -291,7 +285,7 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
- How can I list the current bindings?
`viewconfig nmaps` works OK, but Tridactyl commands won't work on the shown page for "security reasons". We'll eventually provide a better way. See [#98](https://github.com/tridactyl/tridactyl/issues/98). You can also look at `:bind ` completions and `:apropos `
`viewconfig nmaps` works OK, but Tridactyl commands won't work on the shown page for "security reasons". We'll eventually provide a better way. See [#98](https://github.com/tridactyl/tridactyl/issues/98).
- How can I know which mode I'm in/have a status line?
@ -299,7 +293,7 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
- Does anyone actually use Tridactyl?
In addition to the developers, some other people do. Mozilla keeps tabs on stable users [here](https://addons.mozilla.org/en-US/firefox/addon/tridactyl-vim/statistics/?last=30), but, as of a while ago, you can't see that link if you aren't listed as a Tridactyl developer on the AMO. The maintainers guess the number of unstable users from unique IPs downloading the betas each week when they feel like it. Last time they checked there were 4600 of them. Unscientifically extrapolating from Arch linux's pop contest for package installations gives us an estimate of around 50,000 users.
In addition to the developers, some other people do. Mozilla keeps tabs on stable users [here](https://addons.mozilla.org/en-US/firefox/addon/tridactyl-vim/statistics/?last=30), but, as of a while ago, you can't see that link if you aren't listed as a Tridactyl developer on the AMO. The maintainers guess the number of unstable users from unique IPs downloading the betas each week when they feel like it. Last time they checked there were 4600 of them.
- How do I prevent websites from stealing focus?
@ -309,7 +303,7 @@ You can bind your own shortcuts in normal mode with the `:bind` command. For exa
### Donations
We gratefully accept donations via [GitHub Sponsors](https://github.com/users/bovine3dom/sponsorship) (we receive 100% of your donation), [Liberapay](https://liberapay.com/bovine3dom/) (about 95% of your donation makes it to our account), [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7JQHV4N2YZCTY) (about 70% of your donation makes it to our bank account after fees) and [Patreon](https://www.patreon.com/tridactyl) (about 70% of your donation makes it to our account). If you can, please make this a monthly donation as it makes it much easier to plan. People who donate more than 10USD a month via GitHub or Patreon get a special monthly "tips and tricks" newsletter - see an example [here](https://github.com/tridactyl/tridactyl/blob/master/doc/newsletters/tips-and-tricks/1-hint-css-selectors.md). All GitHub and Patreon donors get a quarterly newsletter on Tridactyl development.
We gratefully accept donations via [GitHub Sponsors](https://github.com/users/bovine3dom/sponsorship) (we receive 100% of your donation), [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7JQHV4N2YZCTY) (about 70% of your donation makes it to our bank account after fees) and [Patreon](https://www.patreon.com/tridactyl) (about 70% of your donation makes it to our account). If you can, please make this a monthly donation as it makes it much easier to plan. People who donate more than 10USD a month via GitHub or Patreon get a special monthly "tips and tricks" newsletter - see an example [here](https://github.com/tridactyl/tridactyl/blob/master/doc/newsletters/tips-and-tricks/1-hint-css-selectors.md). All GitHub and Patreon donors get a quarterly newsletter on Tridactyl development.
<a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7JQHV4N2YZCTY"><img src="https://www.paypalobjects.com/en_US/GB/i/btn/btn_donateCC_LG.gif" alt="PayPal"></a>
@ -349,8 +343,6 @@ If you want to build a signed copy (e.g. for the non-developer release), you can
You can build unsigned copies with `scripts/sign nosign{stable,beta}`. NB: The `stable` versus `beta` part of the argument tells our build process which extension ID to use (and therefore which settings to use). If you want a stable build, make sure you are on the latest tag, i.e. `git checkout $(git tag | grep '^[0-9]\+\.[0-9]\+\.[0-9]\+$' | sort -t. -k 1,1n -k 2,2n -k 3,3n | tail -1)`.
Maintainers create the next release after fetching tags with `scripts/version.js next {0,1,2} [release name]` and committing the changed manifest. Beta builds use that version while retaining the release name in their displayed version. Once its changelog entry is ready, `scripts/version.js release` dates the entry, commits it, and tags the version already in `src/manifest.json`.
If you are on a distribution which builds Firefox with `--with-unsigned-addon-scopes=` set to `app` and/or `system` (which is most of them by users: Arch, Debian, Ubuntu), you can install your unsigned copy of Tridactyl with `scripts/install.sh [directory]`. If you're on Arch, the correct directory is probably selected by default; on other distributions you might have to go hunting, but it probably looks like `/usr/lib/firefox/browser/extensions`.
### Building on Windows
@ -359,11 +351,13 @@ If you are on a distribution which builds Firefox with `--with-unsigned-addon-sc
- Install [NodeJS for Windows][win-nodejs]
- Current 8.11.1 LTS seems to work fine
- Launch the installation steps described above from MinTTY shell
- Also known as "Git Bash"
[win-git]: https://git-scm.com/download/win
[win-nodejs]: https://nodejs.org/dist/
[win-nodejs]: https://nodejs.org/dist/v8.11.1/node-v8.11.1-x64.msi
[pyinstaller]: https://www.pyinstaller.org
[gpg4win]: https://www.gpg4win.org
@ -381,7 +375,7 @@ You can speed up the build process after your first build by using `yarn run reb
### Committing
A pre-commit hook is added by `yarn install` that simply runs `yarn test`. If you know that your commit doesn't break the tests you can commit with `git commit -n` to ignore the hooks. If you're making a PR, GitHub will check your build anyway once a maintainer has approved it.
A pre-commit hook is added by `yarn install` that simply runs `yarn test`. If you know that your commit doesn't break the tests you can commit with `git commit -n` to ignore the hooks. If you're making a PR, travis will check your build anyway.
### Documentation

View file

@ -12,7 +12,7 @@ authors="../../build/static/authors.html"
sed "/REPLACETHIS/,$ d" authors.html > "$authors"
# If we're in a git repo, refresh the cache
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if [ -d "../../.git/" ]; then
git shortlog -sn HEAD | cut -c8- | awk '!seen[$0]++' | sed 's/^/<p>/' | sed 's/$/<\/p>/' > ../../.build_cache/authors
fi

View file

@ -42,7 +42,6 @@ mkdir -p build
mkdir -p build/static
mkdir -p generated/static
mkdir -p generated/static/clippy
node scripts/make_glossary.js
if [ "$(isWindowsMinGW)" = "True" ]; then
$WIN_PYTHON scripts/excmds_macros.py
@ -58,19 +57,13 @@ if [ "$QUICK_BUILD" != "1" ]; then
"$(yarn bin)/nearleyc" src/grammars/bracketexpr.ne > \
src/grammars/.bracketexpr.generated.ts
printf '{"version":1,"commands":{},"settings":{}}' > src/.metadata.generated.json
printf 'export * from "./lib/metadata"\n' > src/.metadata.generated.ts
node -e "var fs=require('fs');fs.writeFileSync('src/.themes.generated.json',JSON.stringify(fs.readdirSync('src/static/themes').sort()))"
# Generate runtime metadata via typedoc. The generated TypeScript shim
# routes the public @src/.metadata.generated import to the JSON loader.
"$(yarn bin)/typedoc" --json src/.metadata.generated.json \
--excludeExternals --disableSources --readme none \
--excludePrivate false --excludePrivateClassFields false \
--validation.notExported false \
--exclude 'src/.excmds_*.generated.ts' \
src/excmds.ts src/lib/config.ts src/content/state_content.ts
node scripts/convert_typedoc_metadata.js src/.metadata.generated.json
# It's important to generate the metadata before the documentation because
# missing imports might break documentation generation on clean builds
"$(yarn bin)/tsc" compiler/gen_metadata.ts -m commonjs --target es2017 \
&& node compiler/gen_metadata.js \
--out src/.metadata.generated.ts \
--themeDir src/static/themes \
src/excmds.ts src/lib/config.ts
scripts/newtab.md.sh
scripts/make_tutorial.sh
@ -92,7 +85,7 @@ rmdir buildtemp
# Copy extra static files across
node scripts/generate_manifest.js firefox src/manifest.json build/manifest.json
cp src/manifest.json build/
cp -r src/static build
cp -r generated/static build
cp issue_template.md build/

View file

@ -12,12 +12,51 @@ cachedPrettierFiles() {
git diff --cached --name-only --diff-filter=ACM "*.md" "*.css"
}
noisy() {
# Accepts a single argument which is the name of a file tracked by git
# Returns a string which is the content of the file as stored in the git index
staged() {
git show :"$1"
}
# Accepts a single string argument made of multiple file names separated by a newline
# Returns an array of files that prettier wants to lint
prettierUgly() {
local acc=""
for jsfile in "$@"; do
if [ "$(git diff --cached "$jsfile" | grep '^+.*console.log' -c)" -gt '0' ] ; then
acc+="$acc$jsfile"$'\n'
fi
local IFS=$'\n'
for jsfile in $1; do
diff <(staged "$jsfile") <(staged "$jsfile" | "$(yarn bin)/prettier" --stdin-filepath "$jsfile") >/dev/null || acc="$jsfile"$'\n'"$acc"
done
echo "$acc"
}
eslintUgly() {
local acc=""
local IFS=$'\n'
local tmpdir
mkdir -p ".tmp"
if [[ "$(uname)" == "Darwin" ]]; then
tmpdir=$(gmktemp --tmpdir=".tmp/" -d "tslint.XXXXXXXXX")
else
tmpdir=$(mktemp --tmpdir=".tmp/" -d "tslint.XXXXXXXXX")
fi
for jsfile in "$@"; do
tmpfile="$tmpdir/$jsfile"
mkdir -p "$(dirname "$tmpfile")"
staged "$jsfile" > "$tmpfile"
"$(yarn bin)/eslint" --rulesdir custom-eslint-rules --no-ignore --quiet -o /dev/null "$tmpfile" || acc="$jsfile"$'\n'"$acc"
done
rm -rf "$tmpdir"
echo "$acc"
}
noisy() {
local acc=()
for jsfile in "$@"; do
if [ "$(git diff --cached "$jsfile" | grep '^+.*console.log' -c)" -gt '0' ] ; then
acc+=("jsfile")
fi
done
echo "${acc[@]}"
}

View file

@ -1,210 +0,0 @@
#!/usr/bin/env node
const fs = require("fs")
const TYPEDOC_SCHEMA_VERSION = "2.0"
const METADATA_VERSION = 1
const KIND = { Module: 2, Function: 64, Class: 128 }
const compareNames = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
const sortedRecord = entries =>
Object.fromEntries(entries.sort(([a], [b]) => compareNames(a, b)))
function convertMetadata(project) {
if (project.schemaVersion !== TYPEDOC_SCHEMA_VERSION)
throw new Error(
`Unsupported TypeDoc schemaVersion ${JSON.stringify(project.schemaVersion)}; expected ${TYPEDOC_SCHEMA_VERSION}`,
)
const reflections = {}
const collect = value => {
if (!value || typeof value !== "object") return
if (Array.isArray(value)) return value.forEach(collect)
if (value.id !== undefined) reflections[value.id] = value
Object.values(value).forEach(collect)
}
collect(project)
const accessor = node => node?.getSignature || node?.setSignature
const memberType = node => node?.type || accessor(node)?.type
const commentText = comment =>
(comment?.summary || [])
.map(part => part.text || "")
.join("")
.replace(/\n+$/, "")
const argFlags = comment => {
const flags = {}
for (const tag of comment?.blockTags || []) {
if (tag.tag !== "@flag") continue
const text = (tag.content || [])
.map(part => part.text || "")
.join("")
const flagText = text.split(/\r?\n[ \t]*\r?\n/, 1)[0]
const m = /^(-\S+)[ \t]+([^\n]+)\n*([\s\S]*)$/.exec(flagText.trim())
if (!m) continue
const [, flag, short, rest] = m
const elaboration = rest.trim()
flags[flag] = {
short,
description: elaboration ? `${short}\n\n${elaboration}` : short,
}
}
return flags
}
const normalizeParameter = (parameter, resolving) => ({
name: parameter.name,
type: normalizeType(parameter.type, resolving),
...(parameter.flags?.isRest ? { flags: { isRest: true } } : {}),
})
const normalizeType = (type, resolving = new Set()) => {
if (!type) return { type: "intrinsic", name: "any" }
if (
type.type === "reference" &&
typeof type.target === "number" &&
!type.typeArguments?.length &&
reflections[type.target]?.type &&
!resolving.has(type.target)
) {
const next = new Set(resolving)
next.add(type.target)
return normalizeType(reflections[type.target].type, next)
}
switch (type.type) {
case "intrinsic":
return { type: "intrinsic", name: type.name }
case "array":
return {
type: "array",
elementType: normalizeType(type.elementType, resolving),
}
case "tuple":
return {
type: "tuple",
elements: (type.elements || []).map(element =>
normalizeType(element, resolving),
),
}
case "union":
return {
type: "union",
types: (type.types || []).map(member =>
normalizeType(member, resolving),
),
}
case "literal":
return { type: "literal", value: type.value }
case "reference":
return {
type: "reference",
name: type.name,
...(type.typeArguments?.length
? {
typeArguments: type.typeArguments.map(argument =>
normalizeType(argument, resolving),
),
}
: {}),
}
case "reflection": {
const source = type.declaration || {}
const declaration = {}
const signature = source.signatures?.[0]
if (signature)
declaration.signatures = [
{
parameters: (signature.parameters || []).map(
parameter =>
normalizeParameter(parameter, resolving),
),
type: normalizeType(signature.type, resolving),
},
]
const children = (source.children || [])
.map(child => {
const type = memberType(child)
return type
? {
name: child.name,
type: normalizeType(type, resolving),
}
: undefined
})
.filter(Boolean)
.sort((a, b) => compareNames(a.name, b.name))
if (children.length) declaration.children = children
const indexSignatures = (source.indexSignatures || []).map(
signature => ({
type: normalizeType(signature.type, resolving),
}),
)
if (indexSignatures.length)
declaration.indexSignatures = indexSignatures
return { type: "reflection", declaration }
}
default:
return { type: "intrinsic", name: "any" }
}
}
const modules = Object.fromEntries(
(project.children || [])
.filter(node => node.kind === KIND.Module)
.map(node => [
(node.name || "").replace(/^"|"$/g, "").replace(/\\/g, "/"),
node,
]),
)
const excmds = modules.excmds
const configClass = modules["lib/config"]?.children?.find(
node => node.kind === KIND.Class && node.name === "default_config",
)
if (!excmds || !configClass)
throw new Error("TypeDoc output is missing excmds or default_config")
const commands = sortedRecord(
(excmds.children || [])
.filter(node => node.kind === KIND.Function)
.map(node => {
const signature = node.signatures?.[0]
const comment = signature?.comment || node.comment
const flags = argFlags(comment)
return [
node.name,
{
doc: commentText(comment),
params: (signature?.parameters || []).map(parameter =>
normalizeParameter(parameter),
),
...(Object.keys(flags).length ? { flags } : {}),
},
]
}),
)
const settings = sortedRecord(
(configClass.children || []).map(node => [
node.name,
{
doc:
commentText(accessor(node)?.comment) ||
commentText(node.comment),
type: normalizeType(memberType(node)),
},
]),
)
return { version: METADATA_VERSION, commands, settings }
}
module.exports = { convertMetadata, METADATA_VERSION, TYPEDOC_SCHEMA_VERSION }
if (require.main === module) {
const file = process.argv[2]
if (!file) throw new Error("Usage: convert_typedoc_metadata.js FILE")
const metadata = convertMetadata(JSON.parse(fs.readFileSync(file, "utf8")))
fs.writeFileSync(file, JSON.stringify(metadata))
}

View file

@ -1,6 +1,6 @@
const esbuild = require('esbuild')
for (let f of ["content", "background", "help", "newtab", "reader", "commandline_frame", "qrCodeGenerator", "browser_action_popup"]) {
for (let f of ["content", "background", "help", "newtab", "reader", "commandline_frame", "qrCodeGenerator"]) {
esbuild.build({
entryPoints: [`src/${f}.ts`],
bundle: true,

View file

@ -201,5 +201,5 @@ def main():
print(output.rstrip(), file=sink)
PRELUDE = "/* Generated from excmds.ts. Don't edit this file! */"
PRELUDE = "/** Generated from excmds.ts. Don't edit this file! */"
main()

View file

@ -1,30 +0,0 @@
#!/usr/bin/env node
const fs = require("fs")
const targets = require("../browser-targets.json")
function generateManifest(template, targetName) {
const target = targets[targetName]
if (!target || !target.manifestVersionPath) {
throw new Error(`No manifest settings for ${targetName}`)
}
const manifest = JSON.parse(JSON.stringify(template))
const path = target.manifestVersionPath.slice()
const property = path.pop()
const parent = path.reduce((value, name) => {
if (!value[name]) value[name] = {}
return value[name]
}, manifest)
parent[property] = target.minimumVersion
return manifest
}
if (require.main === module) {
const [targetName, source, destination] = process.argv.slice(2)
const template = JSON.parse(fs.readFileSync(source, "utf8"))
const manifest = generateManifest(template, targetName)
fs.writeFileSync(destination, JSON.stringify(manifest, null, 4) + "\n")
}
module.exports = generateManifest

View file

@ -1,24 +1,4 @@
#!/bin/sh
set -e
dest=generated/static/docs
if revision=$(git rev-parse HEAD 2>/dev/null); then
set -- --basePath src --disableGit --gitRevision "$revision" \
--sourceLinkTemplate 'https://github.com/tridactyl/tridactyl/blob/{gitRevision}/src/{path}#L{line}'
else
set -- --disableSources
fi
"$(yarn bin)/typedoc" --plugin ./scripts/typedoc-theme.mjs \
--theme tridactyl --router tridactyl \
"$@" \
--entryPointStrategy expand --validation.notExported false \
--excludeInternal \
--excludePrivate false --excludePrivateClassFields false \
--includeHierarchySummary false \
--exclude "src/**/?(test_utils|*.test).ts" \
--out "$dest" src
for command in next_completion prev_history accept_line; do
grep -q "id=\"$command\"" "$dest/modules/_src_commandline_frame_.html"
done
rm -rf build/static/docs
cp -r "$dest" build/static/
"$(yarn bin)/typedoc" --theme src/static/typedoc/ --exclude "src/**/?(test_utils|*.test).ts" --out $dest src --ignoreCompilerErrors
cp -r $dest build/static/

View file

@ -1,76 +0,0 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const source = path.join(root, "doc/glossary.jsonl")
const entries = []
const anchors = new Set()
for (const [index, line] of fs
.readFileSync(source, "utf8")
.split("\n")
.entries()) {
if (!line.trim()) continue
let entry
try {
entry = JSON.parse(line)
} catch (error) {
throw new Error(`${source}:${index + 1}: ${error.message}`)
}
if (
!entry ||
typeof entry.word !== "string" ||
typeof entry.definition !== "string" ||
!entry.word.trim() ||
!entry.definition.trim()
)
throw new Error(
`${source}:${index + 1}: word and definition must be non-empty strings`,
)
const word = entry.word.trim().normalize("NFC")
const definition = entry.definition.trim()
const anchor = word.toLowerCase()
if (anchors.has(anchor))
throw new Error(
`${source}:${index + 1}: duplicate word ${JSON.stringify(word)}`,
)
anchors.add(anchor)
entries.push({ word, definition, anchor })
}
if (!entries.length) throw new Error(`${source}: glossary must not be empty`)
entries.sort((a, b) => (a.word < b.word ? -1 : a.word > b.word ? 1 : 0))
const escape = value =>
value.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`)
const body = [
"<h1>Glossary</h1>",
"<p>Terms used in Tridactyl&#39;s documentation.</p>",
'<dl class="glossary-list">',
...entries.map(
entry =>
`<div class="glossary-entry" id="${escape(entry.anchor)}"><dt><code>${escape(entry.word)}</code></dt><dd>${escape(entry.definition)}</dd></div>`,
),
"</dl>",
].join("\n")
let html = fs.readFileSync(
path.join(root, "src/static/clippy/tutor.template.html"),
"utf8",
)
for (const [marker, replacement] of [
["<title>Tridactyl Tutorial</title>", "<title>Tridactyl Glossary</title>"],
['href="./glossary.html"', 'aria-current="page" href="./glossary.html"'],
["REPLACETHIS", body],
]) {
if (html.split(marker).length !== 2)
throw new Error(
`Expected one ${JSON.stringify(marker)} in tutor.template.html`,
)
html = html.replace(marker, replacement)
}
fs.writeFileSync(
path.join(root, "src/.glossary.generated.json"),
JSON.stringify(entries),
)
fs.writeFileSync(path.join(root, "generated/static/clippy/glossary.html"), html)

View file

@ -13,9 +13,8 @@ dest="../../../generated/static/clippy/"
for page in $pages
do
fileroot=$(echo "$page" | cut -d'.' -f-2)
sed -e "s|href=\"$fileroot.html\"|aria-current=\"page\" &|" \
-e "/REPLACETHIS/,$ d" tutor.template.html > "$dest$fileroot.html"
sed "/REPLACETHIS/,$ d" tutor.template.html > "$dest$fileroot.html"
"$(yarn bin)/marked" "$page" >> "$dest$fileroot.html"
sed "1,/REPLACETHIS/ d" tutor.template.html >> "$dest$fileroot.html"
sed -i.bak "s|\(href=['\"]\./[^'\"]*\)\.md\(['\"]\)|\1.html\2|g" "$dest$fileroot.html"
sed -i.bak "s|\.md|.html|g" "$dest$fileroot.html"
done

View file

@ -1,7 +0,0 @@
#!/usr/bin/env node
require("fs").writeFileSync(
process.argv[2],
JSON.stringify(
JSON.parse(require("fs").readFileSync(process.argv[2], "utf8")),
),
)

View file

@ -20,14 +20,13 @@ sed "1,/REPLACETHIS/ d" newtab.template.html >> "$newtabtemp"
sed "/REPLACE_ME_WITH_THE_CHANGE_LOG_USING_SED/,$ d" "$newtabtemp"
# Note: If you're going to change this HTML, make sure you don't break the JS in src/newtab.ts
cat <<EOF
<details id="changelog-details">
<summary><span id="nagbar-changelog">New features!</span>Changelog</summary>
<input type="checkbox" id="spoilerbutton" />
<label for="spoilerbutton" onclick=""><div id="nagbar-changelog">New features!</div>Changelog</label>
<div id="changelog" class="spoiler">
EOF
"$(yarn bin)/marked" ../../CHANGELOG.md
echo """
</div>
</details>
"""
sed "1,/REPLACE_ME_WITH_THE_CHANGE_LOG_USING_SED/ d" "$newtabtemp"
) > "$newtab"

View file

@ -10,10 +10,6 @@ echoe() {
echo "$@" >&2
}
staged() {
git show :"$1"
}
lock() {
local lockfile="$1"
if [ -e "$lockfile" ]; then
@ -31,14 +27,13 @@ unlock() {
trap 'unlock $(git rev-parse --show-toplevel)/.git/index.lock || true' ERR
main() {
local stagedFiles originalIndex
local stagedFiles
stagedFiles="$(cachedTSLintFiles)"$'\n'"$(cachedPrettierFiles)"
if [ -n "$stagedFiles" ]; then
# Could use git-update-index --cacheinfo to add a file without creating directories and stuff.
IFS=$'\n'
for file in $stagedFiles; do
originalIndex=$(git write-tree)
if cmp -s <(staged "$file") "$file"; then
echo "WARN: Staged copy of '$file' matches working copy. Modifying both"
echo "WARN: Modifications may break builds: check that your code still builds"
@ -75,8 +70,6 @@ main() {
rm -rf "$tmpdir"
)
fi
echo "Changes made by pretty to '$file':"
git diff --cached "$originalIndex" -- "$file"
done
fi
}

View file

@ -1,45 +0,0 @@
#!/usr/bin/env node
const { spawnSync } = require("child_process")
const fs = require("fs")
const os = require("os")
const path = require("path")
const firefoxDir =
process.platform === "win32"
? path.join(process.env.APPDATA || os.homedir(), "Mozilla", "Firefox")
: process.platform === "darwin"
? path.join(os.homedir(), "Library", "Application Support", "Firefox")
: path.join(os.homedir(), ".mozilla", "firefox")
function findDefaultProfile() {
const sections = []
let section
for (const line of fs.readFileSync(path.join(firefoxDir, "profiles.ini"), "utf8").split(/\r?\n/)) {
const header = line.trim().match(/^\[([^\]]+)\]$/)
if (header) {
section = { name: header[1] }
sections.push(section)
} else if (section) {
const separator = line.indexOf("=")
if (separator > 0) section[line.slice(0, separator)] = line.slice(separator + 1)
}
}
const profilePath =
sections.find(section => section.name.startsWith("Profile") && section.Name === "default-release")?.Path ??
sections.find(section => section.name.startsWith("Install") && section.Default)?.Default ??
sections.find(section => section.name.startsWith("Profile") && section.Default === "1")?.Path
if (!profilePath) throw new Error(`No default profile found in ${firefoxDir}`)
return path.isAbsolute(profilePath) ? profilePath : path.resolve(firefoxDir, profilePath)
}
try {
const profile = process.argv[2] || findDefaultProfile()
const command = process.platform === "win32" ? "web-ext.cmd" : "web-ext"
const result = spawnSync(command, ["run", "--source-dir", "build/", "--firefox", "deved", "--pre-install", "--firefox-profile", profile, "--no-reload", "--pref", "browser.startup.page=3", "--pref", "browser.sessionstore.resume_from_crash=true"], { stdio: "inherit" })
if (result.error) throw result.error
process.exitCode = result.status ?? 1
} catch (error) {
console.error(`runwithprofile: ${error.message}`)
process.exitCode = 1
}

View file

@ -4,7 +4,7 @@ set -e
sign_and_submit() {
# Don't trust the return value of web-ext sign.
(set +x; source AMOKEYS && (yarn run web-ext sign -s build --api-key "$AMOKEY" --api-secret "$AMOSECRET" "$@" || true))
(source AMOKEYS && (yarn run web-ext sign -s build --api-key "$AMOKEY" --api-secret "$AMOSECRET" "$@" || true))
}
publish_beta_nonewtab() {
@ -30,7 +30,6 @@ build_no_sign_beta(){
yarn run clean
yarn run build --no-native
scripts/version.js beta
node -e 'const fs = require("fs"), manifest = require("./build/manifest.json"); delete manifest.applications.gecko.update_url; fs.writeFileSync("./build/manifest.json", JSON.stringify(manifest, null, 4))'
sed 's/"name": "Tridactyl"/"name": "Tridactyl: Beta"/' -i build/manifest.json
mkdir -p web-ext-artifacts
yarn run web-ext build --source-dir ./build --overwrite-dest

View file

@ -4,10 +4,9 @@ export LAST_VERSION="$1"
allcontributors="$(git shortlog -sn $LAST_VERSION..HEAD | cut -c8- | awk '!seen[$0]++' | paste -sd "," - | sed 's/,/, /g')"
newcontributors="$(diff --changed-group-format='%<' --unchanged-group-format='' <(git shortlog -sn $LAST_VERSION..HEAD | cut -c8- | awk '!seen[$0]++'| sort) <(git shortlog -sn $LAST_VERSION | cut -c8- | awk '!seen[$0]++'| sort) | paste -sd "," - | sed 's/,/, /g')"
issuereporters="$(gh issue list --state closed --search "closed:>$(git log -1 --format=%cI "$LAST_VERSION")" --limit 10000 --json author --jq '.[] | select(.author) | .author.login' | sort | uniq -c | sort -k1,1nr -k2,2 | awk '{print $2}' | paste -sd "," - | sed 's/,/, /g; s#app/#people who have since deleted their GitHub accounts#g')"
echo "Thanks to all of our contributors for this release: $allcontributors"
echo
echo "Extra special thanks go to $newcontributors"
echo
echo "Last, but not least - thank you to everyone who reported the issues we closed in this release: $issuereporters"
echo Last, but not least - thank you to everyone who reported issues.

View file

@ -1,471 +0,0 @@
import {
Converter,
DefaultTheme,
IntrinsicType,
JSX,
KindRouter,
PageKind,
Reflection,
ReflectionKind,
ReflectionType,
Slugger,
TypeScript as ts,
} from "typedoc"
const h = JSX.createElement
const css = href => h("link", { rel: "stylesheet", href })
const cleanName = name => name.replace(/[^a-z0-9]/gi, "_").toLowerCase()
const isGeneratedSource = source =>
/\/\.[^/]+\.generated\.ts$/.test(source.fullFileName)
function rewriteWikiLinks(parts, owner, reflections) {
return parts.flatMap(part => {
if (part.kind !== "text" || !part.text.includes("[[")) return part
const out = []
let end = 0
for (const match of part.text.matchAll(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
)) {
if (match.index > end)
out.push({
kind: "text",
text: part.text.slice(end, match.index),
})
const candidates = reflections.get(match[1]) || []
const ownerModule = getModule(owner)
const target =
(!owner.kindOf(
ReflectionKind.Module | ReflectionKind.Namespace,
) &&
candidates.find(
candidate => candidate.parent === owner.parent,
)) ||
candidates.find(
candidate => getModule(candidate) === ownerModule,
) ||
candidates.find(
candidate =>
!candidate.kindOf(
ReflectionKind.Module | ReflectionKind.Namespace,
) && !candidate.sources?.some(isGeneratedSource),
) ||
candidates.find(
candidate =>
!candidate.kindOf(
ReflectionKind.Module | ReflectionKind.Namespace,
),
) ||
candidates[0]
out.push(
target
? {
kind: "inline-tag",
tag: "@link",
text: match[2] || match[1],
target,
}
: { kind: "text", text: match[2] || match[1] },
)
end = match.index + match[0].length
}
if (end < part.text.length)
out.push({ kind: "text", text: part.text.slice(end) })
return out
})
}
function getModule(reflection) {
while (reflection.parent && !reflection.parent.isProject())
reflection = reflection.parent
return reflection
}
function escapeDependencyTags(parts) {
return parts.map(part =>
part.kind === "text"
? {
...part,
text: part.text.replace(
/<(\/?)(iframe|object|frame)>/gi,
"&lt;$1$2&gt;",
),
}
: part,
)
}
function restoreWikiLinks(context) {
const reflections = new Map()
for (const reflection of Object.values(context.project.reflections)) {
if (!reflection.isDeclaration()) continue
const matches = reflections.get(reflection.name) || []
matches.push(reflection)
reflections.set(reflection.name, matches)
}
for (const reflection of Object.values(context.project.reflections)) {
const fromDependency = reflection.sources?.some(source =>
source.fullFileName.includes("/node_modules/"),
)
for (const source of reflection.sources || [])
if (
source.fullFileName.includes("/node_modules/") ||
isGeneratedSource(source)
)
source.url = undefined
const comment = reflection.comment
if (!comment) continue
comment.summary = rewriteWikiLinks(
comment.summary,
reflection,
reflections,
)
for (const tag of comment.blockTags)
tag.content = rewriteWikiLinks(tag.content, reflection, reflections)
if (fromDependency) {
comment.summary = escapeDependencyTags(comment.summary)
for (const tag of comment.blockTags)
tag.content = escapeDependencyTags(tag.content)
}
}
if (context.project.readme)
context.project.readme = rewriteWikiLinks(
context.project.readme,
context.project,
reflections,
)
}
function restoreObjectLiteral(context, reflection) {
const node = context.getSymbolFromReflection(reflection)?.valueDeclaration
const initializer = node && "initializer" in node && node.initializer
if (!initializer || !ts.isObjectLiteralExpression(initializer)) return
if (!initializer.properties.length) return
if (reflection.type instanceof ReflectionType)
context.project.removeTypeReflections(reflection.type)
const inferred = context.converter.convertType(
context.withScope(reflection),
context.checker.getTypeAtLocation(initializer),
)
if (!(inferred instanceof ReflectionType)) return
reflection.type = new IntrinsicType("object")
context.project.mergeReflections(inferred.declaration, reflection)
}
function restoreCommandlineFns(context) {
const commandline = context.project.getChildByName("commandline_frame")
const functions = commandline?.getChildByName("commandlineFns")
if (functions) context.project.mergeReflections(functions, commandline)
}
class LegacySlugger extends Slugger {
serialize(value) {
return value.startsWith("\0") ? value.slice(1) : super.serialize(value)
}
}
class TridactylRouter extends KindRouter {
aliases = new Map()
aliasCounts = new Map()
getSlugger(target) {
while (!this.sluggers.has(target)) target = target.parent
let slugger = this.sluggers.get(target)
if (!(slugger instanceof LegacySlugger)) {
slugger = new LegacySlugger(this.sluggerConfiguration)
this.sluggers.set(target, slugger)
}
return slugger
}
getPageKind(target) {
if (!(target instanceof Reflection)) return
if (
target.kindOf(
ReflectionKind.Class |
ReflectionKind.Interface |
ReflectionKind.Enum |
ReflectionKind.Module |
ReflectionKind.Namespace,
)
)
return PageKind.Reflection
if (target.kindOf(ReflectionKind.Document)) return PageKind.Document
}
getIdealBaseName(reflection) {
const directory = this.directories.get(reflection.kind)
const parts = []
do {
let name = reflection.name
if (
reflection.parent?.isProject() &&
reflection.kind === ReflectionKind.Module
)
name = `"src/${name}"`
parts.unshift(cleanName(name))
reflection = reflection.parent
} while (reflection && !reflection.isProject())
return `${directory}/${parts.join(".")}`
}
getLegacyAlias(reflection, pageTarget) {
if (this.aliases.has(reflection)) return this.aliases.get(reflection)
const base = cleanName(reflection.name) || `reflection-${reflection.id}`
let counts = this.aliasCounts.get(pageTarget)
if (!counts) this.aliasCounts.set(pageTarget, (counts = new Map()))
const count = counts.get(base) || 0
const alias = count ? `${base}-${count}` : base
counts.set(base, count + 1)
this.aliases.set(reflection, alias)
return alias
}
createAnchor(target, pageTarget) {
if (!target.isDeclaration())
return super.createAnchor(target, pageTarget)
const parts = []
for (
let current = target;
current !== pageTarget;
current = current.parent
)
parts.unshift(this.getLegacyAlias(current, pageTarget))
return this.getSlugger(pageTarget).slug("\0" + parts.join("."))
}
buildPages(project) {
this.aliases.clear()
this.aliasCounts.clear()
const pages = super.buildPages(project)
const globals = pages.find(page => page.url === "modules.html")
if (globals) {
globals.url = "globals.html"
this.fullUrls.set(project, "globals.html")
}
return pages
}
}
function parseFlagTag(tag) {
const parts = tag.content || []
let text = ""
let trailing = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const match = part.kind === "text" && /\r?\n[ \t]*\r?\n/.exec(part.text)
if (!match) {
text += part.text || ""
continue
}
text += part.text.slice(0, match.index)
trailing = [
{
...part,
text: part.text.slice(match.index + match[0].length),
},
...parts.slice(i + 1),
]
break
}
const m = /^(-\S+)[ \t]+([^\n]+)\n*([\s\S]*)$/.exec(text.trim())
if (!m) return undefined
const [, flag, short, rest] = m
const elaboration = rest.trim()
return [flag, short, elaboration, trailing]
}
function renderFlagList(context, parsed) {
if (parsed.length === 0) return null
return h(
"ul",
{ class: "tsd-tag-flag tsd-parameter-list" },
parsed.map(([flag, short, elaboration]) =>
h(
"li",
null,
h("code", null, flag),
" ",
short,
elaboration &&
context.displayParts([{ kind: "text", text: elaboration }]),
),
),
)
}
class TridactylTheme extends DefaultTheme {
getRenderContext(page) {
const context = super.getRenderContext(page)
const defaultCommentSummary = context.commentSummary
context.commentSummary = props => {
const blockTags = props.comment?.blockTags || []
if (!blockTags.some(tag => tag.tag === "@flag"))
return defaultCommentSummary(props)
const summaryHeadingCount = page.pageHeadings.length
const nodes = [context.displayParts(props.comment?.summary || [])]
page.pageHeadings.length = summaryHeadingCount
let flagRun = []
const flushFlags = () => {
if (flagRun.length === 0) return
nodes.push(renderFlagList(context, flagRun))
flagRun = []
}
for (const tag of blockTags) {
if (tag.tag === "@flag") {
tag.skipRendering = true
const parsed = parseFlagTag(tag)
if (!parsed) continue
const [flag, short, elaboration, trailing] = parsed
flagRun.push([flag, short, elaboration])
if (trailing.length === 0) continue
flushFlags()
const headingCount = page.pageHeadings.length
nodes.push(context.displayParts(trailing))
page.pageHeadings.length = headingCount
}
}
flushFlags()
return h(JSX.Fragment, null, ...nodes)
}
return context
}
getReflectionClasses(reflection) {
const kind = ReflectionKind.classString(reflection.kind)
const parent =
reflection.parent &&
ReflectionKind.classString(reflection.parent.kind).replace(
"tsd-kind-",
"tsd-parent-kind-",
)
return [super.getReflectionClasses(reflection), kind, parent]
.filter(Boolean)
.join(" ")
}
constructor(renderer) {
super(renderer)
const reflectionTemplate = this.reflectionTemplate
this.reflectionTemplate = page => {
const content = reflectionTemplate(page)
if (
!page.model.kindOf(
ReflectionKind.Module | ReflectionKind.Namespace,
)
)
return content
const context = this.getRenderContext(page)
page.pageSections.length = 0
const renderMember = context.member
context.member = reflection =>
reflection.isReference() ? null : renderMember(reflection)
return h(JSX.Fragment, null, content, context.members(page.model))
}
this.defaultLayoutTemplate = (page, template) => {
const context = this.getRenderContext(page)
const content = template(page)
const helpLink = (label, href) =>
h("li", null, h("a", { href }, label))
const docLink = (label, url) =>
helpLink(label, context.relativeURL(url))
return h(
"html",
{ class: "minimal no-js TridactylOwnNamespace" },
h(
"head",
null,
h("meta", { charset: "utf-8" }),
h("meta", {
"http-equiv": "X-UA-Compatible",
content: "IE=edge",
}),
h(
"title",
null,
`${page.model.name} | ${page.project.name}`,
),
h("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1",
}),
css(context.relativeURL("assets/style.css")),
css(context.relativeURL("assets/highlight.css")),
h("script", { src: "/content.js" }),
h("script", { src: "/help.js" }),
css("/static/css/content.css"),
css("/static/css/hint.css"),
css("/static/css/viewsource.css"),
css("/static/typedoc/assets/css/main.css"),
),
h(
"body",
null,
h(
"nav",
{ class: "tsd-navigation secondary scroller" },
h(
"section",
{ class: "TridactylHelpNavigation" },
h("h3", null, "Help Pages"),
h(
"ul",
null,
docLink(
"Commands",
"modules/_src_excmds_.html",
),
docLink(
"Settings",
"classes/_src_lib_config_.default_config.html",
),
docLink("Tutorial", "../clippy/1-tutor.html"),
docLink(
"Editor Functions",
"modules/_src_lib_editor_.html",
),
docLink(
"Command-Line Functions",
"modules/_src_commandline_frame_.html",
),
docLink(
"Hint Mode Commands",
"modules/_src_content_hinting_.html",
),
helpLink(
"Wiki",
"https://github.com/tridactyl/tridactyl/wiki",
),
),
),
context.pageNavigation(page),
),
h(
"div",
{ class: "container container-main scroller" },
h("div", { class: "content-wrap" }, content),
),
),
)
}
}
}
export function load(app) {
app.converter.on(Converter.EVENT_RESOLVE_BEGIN, restoreCommandlineFns)
app.converter.on(Converter.EVENT_RESOLVE_END, restoreWikiLinks, 100)
app.converter.on(
Converter.EVENT_CREATE_DECLARATION,
restoreObjectLiteral,
100,
)
app.renderer.defineRouter("tridactyl", TridactylRouter)
app.renderer.defineTheme("tridactyl", TridactylTheme)
}

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
const { exec, execFileSync } = require("child_process")
const { exec } = require("child_process")
const fs = require("fs")
function bump_version(versionstr, component = 2) {
@ -12,33 +12,7 @@ function bump_version(versionstr, component = 2) {
return versionarr.join(".")
}
function release_name(manifest) {
return (manifest.version_name || manifest.version)
.slice(manifest.version.length)
.trim()
}
function validate_release_version(manifest) {
if (!/^\d+\.\d+\.\d+$/.test(manifest.version) || manifest.version_name !== [manifest.version, release_name(manifest)].filter(Boolean).join(" ")) {
throw new Error("Manifest version_name must be the version followed by an optional release name")
}
}
function tagged(version) {
return fs.existsSync(".git") && execFileSync("git", ["tag", "--list", version]).toString().trim() !== ""
}
function set_release_version(manifest, component, name = "") {
if (![0, 1, 2].includes(component)) throw new Error("Version component must be 0, 1 or 2")
if (component === 2 && name) throw new Error("Only major and minor releases can be named")
const nameForRelease =
component === 2 ? release_name(manifest) : name.trim()
manifest.version = bump_version(manifest.version, component)
manifest.version_name = [manifest.version, nameForRelease].filter(Boolean).join(" ")
}
async function beta_number() {
async function add_beta(versionstr) {
await fs.promises.mkdir(".build_cache", {recursive: true})
try {
await fs.promises.access(".git")
@ -52,7 +26,7 @@ async function beta_number() {
catch {
; // Not in a git directory - don't do anything
}
return (await fs.promises.readFile(".build_cache/count", {encoding: "utf8"})).trim()
return versionstr + "pre" + (await fs.promises.readFile(".build_cache/count", {encoding: "utf8"})).trim()
}
async function get_hash() {
@ -107,76 +81,34 @@ function save_manifest(filename, manifest) {
fs.writeFileSync(filename, JSON.stringify(manifest, null, 4))
}
function set_beta_version(manifest, number, hash) {
const version = manifest.version
const name = release_name(manifest)
manifest.version = `${version}.${number}`
manifest.version_name = [`${version}pre${number}-${hash}`, name].filter(Boolean).join(" ")
}
async function main() {
let filename, manifest
switch (process.argv[2]) {
case "next": {
case "bump":
// Load src manifest and bump
filename = "./src/manifest.json"
manifest = require("." + filename)
if (!tagged(manifest.version)) throw new Error(`Version ${manifest.version} is not tagged`)
set_release_version(
manifest,
manifest.version = bump_version(
manifest.version,
Number(process.argv[3]),
process.argv.slice(4).join(" "),
)
validate_release_version(manifest)
if (tagged(manifest.version)) throw new Error(`Version ${manifest.version} is already tagged`)
manifest.version_name = manifest.version
save_manifest(filename, manifest)
break
}
case "release": {
filename = "./src/manifest.json"
manifest = require("." + filename)
validate_release_version(manifest)
if (tagged(manifest.version)) throw new Error(`Version ${manifest.version} is already tagged`)
if (execFileSync("git", ["status", "--porcelain"]).toString().trim()) throw new Error("Release requires a clean worktree")
const changelog = fs.readFileSync("./CHANGELOG.md", "utf8")
const releaseHeading = `Release ${manifest.version} / Unreleased`
const releaseNotes = changelog
.split(/(?=^#+ Release )/m)
.find(notes =>
notes.split("\n", 1)[0].trimEnd().endsWith(releaseHeading),
)
if (!releaseNotes) {
throw new Error(`No ${releaseHeading} changelog entry`)
}
const datedReleaseNotes = releaseNotes.replace(
"Unreleased",
new Date().toISOString().slice(0, 10),
exec(
`git add ${filename} && git commit -m 'release ${
manifest.version
}' && git tag ${manifest.version}`,
)
fs.writeFileSync(
"./CHANGELOG.md",
changelog.replace(releaseNotes, () => datedReleaseNotes),
)
execFileSync("git", ["add", "./CHANGELOG.md"])
execFileSync("git", [
"commit",
"--cleanup=verbatim",
"-m",
`release ${manifest.version}`,
"-m",
datedReleaseNotes.trim(),
])
execFileSync("git", ["tag", manifest.version])
console.log(
`Make sure you use the release checklist before committing this.`,
)
console.log(`https://github.com/tridactyl/tridactyl/issues/714`)
break
}
case "beta":
filename = "./build/manifest.json"
manifest = require("." + filename)
validate_release_version(manifest)
if (tagged(manifest.version)) throw new Error(`Version ${manifest.version} is already tagged`)
set_beta_version(manifest, await beta_number(), await get_hash())
manifest.version = await add_beta(manifest.version)
manifest.version_name = manifest.version + "-" + (await get_hash())
manifest.applications.gecko.update_url =
"https://tridactyl.cmcaine.co.uk/betas/updates.json"
@ -198,6 +130,4 @@ async function main() {
}
}
if (require.main === module) main()
module.exports = { set_beta_version, set_release_version }
main()

View file

@ -1,22 +0,0 @@
const { set_beta_version, set_release_version } = require("./version")
test("manages major and minor release names", () => {
const manifest = { version: "1.24.6", version_name: "1.24.6" }
set_release_version(manifest, 1, "Carpenter")
expect(manifest.version_name).toBe("1.25.0 Carpenter")
set_release_version(manifest, 2)
expect(manifest.version_name).toBe("1.25.1 Carpenter")
set_release_version(manifest, 0, "Joiner")
expect(manifest.version_name).toBe("2.0.0 Joiner")
expect(() =>
set_release_version({ version: "1.25.0" }, 2, "Carpenter"),
).toThrow()
set_beta_version(manifest, "7685", "1ac287cf")
expect(manifest.version).toBe("2.0.0.7685")
expect(manifest.version_name).toBe("2.0.0pre7685-1ac287cf Joiner")
})

View file

@ -20,25 +20,21 @@ export WINEDEBUG="fixme-all"
# stop wine whining
export DISPLAY=
PREREQUISITES="tput printf sort 7z wine"
PREREQUISITES="tput printf 7z wine"
MIN_WINE_VER="4"
MIN_7ZIP_VER="16"
versionAtLeast() {
printf '%s\n' "$1" "$2" | sort -VC
}
checkRequiredVersions() {
if ! versionAtLeast "${MIN_7ZIP_VER}" "$(7z | awk '/Version/{print $3}')"; then
if ! 7z | awk '/Version/{print $3}' | grep -q "${MIN_7ZIP_VER}"; then
colorEcho \
'[-] p7zip minimum version '"${MIN_7ZIP_VER}"' required\n' \
"alert"
exit 1
fi
if ! versionAtLeast "${MIN_WINE_VER}" "$(wine --version 2> /dev/null | cut -d- -f2-)"; then
colorEcho \
if ! wine --version 2> /dev/null | grep -q "wine-${MIN_WINE_VER}"; then
colorecho \
'[-] wine minimum version '"${MIN_WINE_VER}"' required\n' \
"alert"
exit 1

View file

@ -8,7 +8,6 @@ import { omniscient_controller } from "@src/lib/omniscient_controller"
import * as perf from "@src/perf"
import { listenForCounters } from "@src/perf"
import * as messaging from "@src/lib/messaging"
import { messageTabChanges } from "@src/background/tab_changes"
import * as excmds_background from "@src/.excmds_background.generated"
import { CmdlineCmds } from "@src/background/commandline_cmds"
import { EditorCmds } from "@src/background/editor"
@ -28,7 +27,6 @@ import * as omnibox from "@src/background/omnibox"
import * as R from "ramda"
import * as webrequests from "@src/background/webrequests"
import * as commands from "@src/background/commands"
import * as browser_action from "@src/background/browser_action"
import * as meta from "@src/background/meta"
import * as Logging from "@src/lib/logging"
import * as Proxy from "@src/lib/proxy"
@ -50,10 +48,7 @@ import { tabsProxy } from "@src/lib/tabs"
state,
webext,
webrequests,
l: (value: any) =>
typeof value?.then === "function"
? value.then(console.log).catch(console.error)
: console.log(value),
l: (prom: Promise<any>) => prom.then(console.log).catch(console.error),
contentLocation: window.location,
R,
perf,
@ -74,24 +69,22 @@ controller.setExCmds({
})
// {{{ tri.contentLocation
// When loading the background, use the active tab to know what the current content url is
browser.tabs.query({ currentWindow: true, active: true }).then(t => {
;(window as any).tri.contentLocation = new URL(t[0].url)
})
// After that, on every tab change, update the current url
let contentLocationCount = 0
function updateContentLocation(windowId = browser.windows.WINDOW_ID_CURRENT) {
browser.tabs.onActivated.addListener(ev => {
const myId = contentLocationCount + 1
contentLocationCount = myId
browser.tabs
.query({ windowId, active: true })
.then(t => {
// Ignore stale queries when focus or active tabs change quickly.
if (contentLocationCount === myId && t[0]?.url) {
;(window as any).tri.contentLocation = new URL(t[0].url)
}
})
.catch(() => undefined)
}
browser.tabs.onActivated.addListener(() => updateContentLocation())
browser.windows.onFocusChanged.addListener(windowId => {
if (windowId === browser.windows.WINDOW_ID_NONE) return
updateContentLocation(windowId)
browser.tabs.get(ev.tabId).then(t => {
// Note: we're using contentLocationCount and myId in order to make sure that only the last onActivated event is used in order to set contentLocation
// This is needed because otherWise the following chain of execution might happen: onActivated1 => onActivated2 => tabs.get2 => tabs.get1
if (contentLocationCount === myId) {
;(window as any).tri.contentLocation = new URL(t.url)
}
})
})
browser.proxy.onRequest.addListener(Proxy.onRequestListener, {
@ -101,44 +94,32 @@ browser.proxy.onRequest.addListener(Proxy.onRequestListener, {
/**
* Declare Tab Event Listeners
*/
const tabChangeListener = (command: string) => () => messageTabChanges(command)
browser.tabs.onRemoved.addListener(tabChangeListener("tab_close"))
browser.tabs.onRemoved.addListener(tabId => {
messaging.messageAllTabs("tab_changes", "tab_close", [tabId])
})
// Fired when a tab is attached to a window, for example because it was moved between windows.
browser.tabs.onAttached.addListener(tabChangeListener("tab_attached"))
browser.tabs.onAttached.addListener(tabId => {
messaging.messageAllTabs("tab_changes", "tab_attached", [tabId])
})
// Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired.
browser.tabs.onCreated.addListener(tabChangeListener("tab_created"))
browser.tabs.onCreated.addListener(tabId => {
messaging.messageAllTabs("tab_changes", "tab_created", [tabId])
})
// Fired when a tab is detached from a window, for example because it is being moved between windows.
browser.tabs.onDetached.addListener(tabChangeListener("tab_detached"))
browser.tabs.onDetached.addListener(tabId => {
messaging.messageAllTabs("tab_changes", "tab_detached", [tabId])
})
// Fired when a tab is moved within a window.
browser.tabs.onMoved.addListener(tabChangeListener("tab_moved"))
browser.tabs.onUpdated.addListener(
tabChangeListener("tab_updated"),
{
properties: [
"audible",
"discarded",
"favIconUrl",
"hidden",
"mutedInfo",
"pinned",
"title",
"url",
],
},
)
browser.tabs.onActivated.addListener(tabChangeListener("tab_activated"))
browser.tabs.onMoved.addListener(tabId => {
messaging.messageAllTabs("tab_changes", "tab_moved", [tabId])
})
// Update on navigation too (but remember that sometimes people open tabs in the background :) )
browser.webNavigation.onDOMContentLoaded.addListener(() => {
updateContentLocation()
browser.tabs.query({ currentWindow: true, active: true }).then(t => {
;(window as any).tri.contentLocation = new URL(t[0].url)
})
})
const messageHistoryState = (details: { frameId: number; tabId: number }) => {
if (details.frameId !== 0) return
messaging.messageTab(details.tabId, "history_state").catch(() => undefined)
}
browser.webNavigation.onHistoryStateUpdated.addListener(messageHistoryState)
browser.webNavigation.onReferenceFragmentUpdated.addListener(messageHistoryState)
updateContentLocation()
// Prevent Tridactyl from being updated while it is running in the hope of fixing #290
browser.runtime.onUpdateAvailable.addListener(_ => undefined)
@ -214,6 +195,27 @@ for (const requestEvent of webrequests.requestEvents) {
})
}
config.addChangeListener("autocmds", (previous, current) =>
webrequests.requestEvents.forEach(
requestEvent =>
// If there are autocmd(s) for this requestEvent
current[requestEvent] !== undefined &&
Object.entries(
current[requestEvent] as Record<string, string>,
).forEach(([pattern, func]) => {
// R.path returns undefined if any part of the path is missing rather than saying "computer says no"
const path = R.path([requestEvent, pattern])
// If this is a new autocmd, register it
path(current) !== path(previous) &&
webrequests.registerWebRequestAutocmd(
requestEvent,
pattern,
func,
)
}),
),
)
// }}}
@ -242,7 +244,6 @@ browser.webRequest.onBeforeRequest.addListener(
)
browser.tabs.onCreated.addListener(aucon.tabCreatedListener)
browser.tabs.onRemoved.addListener(aucon.tabRemovedListener)
// }}}
@ -251,18 +252,6 @@ browser.tabs.onRemoved.addListener(aucon.tabRemovedListener)
// An object to collect all of our statistics in one place.
const statsLogger: perf.StatsLogger = new perf.StatsLogger()
const messages = {
browser_action_background: {
getState: browser_action.getState,
toggle: browser_action.toggle,
},
config_background: {
clear: config.clear,
pull: config.pull,
push: config.push,
ready: () => config.getAsync().then(() => undefined),
set: config.set,
unset: config.unset,
},
excmd_background: excmds_background,
controller_background: controller,
performance_background: statsLogger,
@ -298,8 +287,9 @@ omnibox.init()
// }}}
setTimeout(config.update, 5000)
commands.updateListener()
browser_action.init()
// {{{ Obey Mozilla's orders https://github.com/tridactyl/tridactyl/issues/1800

View file

@ -1,43 +0,0 @@
import * as config from "@src/lib/config"
import { getState, init, toggle } from "@src/background/browser_action"
jest.mock("@src/lib/config", () => {
const userconfig = { superignore: "false" }
return {
DEFAULTS: { superignore: "false" },
USERCONFIG: userconfig,
getAsync: jest.fn().mockResolvedValue(undefined),
set: jest.fn((_key, value) => {
userconfig.superignore = value
return Promise.resolve()
}),
addChangeListener: jest.fn(),
}
})
test("browser action toggles superignore without reloading tabs", async () => {
await config.set("superignore", "false")
init()
await expect(getState()).resolves.toBe("false")
await expect(Promise.all([toggle(), toggle()])).resolves.toEqual([
"true",
"false",
])
expect(config.USERCONFIG.superignore).toBe("false")
expect(browser.browserAction.setBadgeText).toHaveBeenCalledWith({
text: "OFF",
})
expect(browser.tabs.reload).not.toHaveBeenCalled()
expect(browser.browserAction.onClicked.addListener).not.toHaveBeenCalled()
expect(browser.browserAction.setTitle).toHaveBeenLastCalledWith({
title: "Tridactyl enabled",
})
jest.mocked(config.set).mockImplementationOnce(async (_key, value) => {
config.USERCONFIG.superignore = value as "true" | "false"
throw new Error("write failed")
})
await expect(toggle()).rejects.toThrow("write failed")
expect(config.USERCONFIG.superignore).toBe("false")
})

View file

@ -1,48 +0,0 @@
import * as config from "@src/lib/config"
const ready = config.getAsync()
const superignore = () =>
config.USERCONFIG.superignore ?? config.DEFAULTS.superignore
function updateButton(value) {
const disabled = value === "true"
return Promise.all([
browser.browserAction.setBadgeText({ text: disabled ? "OFF" : "" }),
browser.browserAction.setTitle({
title: disabled ? "Tridactyl disabled" : "Tridactyl enabled",
}),
])
}
export async function getState() {
await ready
return superignore()
}
let toggleQueue: Promise<unknown> = Promise.resolve()
export function toggle() {
const pending = toggleQueue
.catch(() => undefined)
.then(async () => {
const previous = config.USERCONFIG.superignore
const value = (await getState()) === "true" ? "false" : "true"
try {
await config.set("superignore", value)
} catch (error) {
if (previous === undefined) delete config.USERCONFIG.superignore
else config.USERCONFIG.superignore = previous
throw error
}
updateButton(value).catch(console.error)
return value
})
toggleQueue = pending
return pending
}
export function init() {
config.addChangeListener("superignore", (_, value) =>
updateButton(value).catch(console.error),
)
getState().then(updateButton).catch(console.error)
}

View file

@ -1,38 +0,0 @@
import * as controller from "@src/lib/controller"
import * as config from "@src/lib/config"
import { rcFileToExCmds, runRc } from "@src/background/config_rc"
jest.mock("@src/lib/controller")
global.structuredClone ??= value => JSON.parse(JSON.stringify(value))
const backslash = "\\"
test.each([
[`set foo one ${backslash}\ntwo`, ["set foo one two"]],
[
`keymap foo ${backslash}${backslash}\nset bar baz`,
[`keymap foo ${backslash}`, "set bar baz"],
],
[`keymap foo ${backslash}${backslash}\n`, [`keymap foo ${backslash}`]],
[`keymap foo ${backslash}`, [`keymap foo ${backslash}`]],
])("parses RC line ending backslashes", (rc, expected) => {
expect(rcFileToExCmds(rc)).toEqual(expected)
})
test("runRc updates and saves versioned config", async () => {
await config.clear()
jest.mocked(controller.acceptExCmd).mockImplementation(async cmd => {
const [, key, value] = cmd.split(" ")
await config.set(key, value)
})
await runRc("set configversion 1.0\nset vimium-gi false")
expect(browser.storage.local.set).toHaveBeenLastCalledWith(
expect.objectContaining({
userconfig: expect.objectContaining({
configversion: "2.0",
gimode: "firefox",
}),
}),
)
})

View file

@ -1,5 +1,4 @@
import * as controller from "@src/lib/controller"
import * as config from "@src/lib/config"
import * as Native from "@src/lib/native"
export async function source(filename = "auto") {
@ -49,16 +48,14 @@ export async function writeRc(conf: string, force = false, filename = "auto") {
} else {
path = filename
}
await Native.writerc(path, force, conf)
return path
return await Native.writerc(path, force, conf)
}
export async function runRc(rc: string) {
for (const cmd of rcFileToExCmds(rc)) {
await new Promise(resolve => setTimeout(resolve, 100))
await controller.acceptExCmd(cmd)
}
// Sourced commands have already been saved to the current local config.
await config.update(true)
}
export function rcFileToExCmds(rcText: string): string[] {
@ -72,13 +69,10 @@ export function rcFileToExCmds(rcText: string): string[] {
!x.trim().startsWith('"') &&
!x.trim().startsWith("#"),
)
const res = ex.join("\n") + (rcText.endsWith("\n") ? "\n" : "")
const res = ex.join("\n")
// Join lines ending in an unescaped backslash and unescape trailing pairs.
const joined = res.replace(/(\\+)\n/g, (_, backslashes: string) => {
const escaped = "\\".repeat(Math.floor(backslashes.length / 2))
return escaped + (backslashes.length % 2 === 0 ? "\n" : "")
})
// string-join lines that end with /
const joined = res.replace(/\\\n/g, "")
return joined.replace(/\n$/, "").split("\n")
return joined.split("\n")
}

View file

@ -79,10 +79,10 @@ export async function downloadUrl(url: string, saveAs: boolean) {
*
* Note: this requires a native messenger >=0.1.9. Make sure to nativegate for this.
*
* @param url the URL to download
* @param URL the URL to download
* @param saveAs If beginning with a slash, this is the absolute path the document should be moved to. If the first character of the string is a tilda, it will be expanded to an absolute path to the user's home directory. If saveAs begins with any other character, it will be considered a path relative to where the native messenger binary is located (e.g. "$HOME/.local/share/tridactyl" on linux).
* @param overwrite If true, overwrite the destination file, returns error code 1 otherwise if file exists
* @param cleanup If true, cleans up temporary downloaded source file e.g. in $HOME/Downlods/downloaded.doc when the move operation fails e.g. due to target destination exists, OS error etc.
* @param If true, overwrite the destination file, returns error code 1 otherwise if file exists
* @param If true, cleans up temporary downloaded source file e.g. in $HOME/Downlods/downloaded.doc when the move operation fails e.g. due to target destination exists, OS error etc.
*/
export async function downloadUrlAs(
url: string,
@ -137,18 +137,12 @@ export async function downloadUrlAs(
})
)[0]
if (downloadDelta.state.current === "complete") {
const placeholder = config.get("downloadfilenamemarker")
let finalSaveAs = saveAs
if (placeholder.length > 0 && finalSaveAs.includes(placeholder)) {
finalSaveAs = finalSaveAs.split(placeholder).join(fileName)
}
const operation = await Native.move(
const operation = await Native.move(
downloadItem.filename,
finalSaveAs,
saveAs,
overwrite,
cleanup,
)
)
const code2human = n =>
R.defaultTo(
"Unknown error",
@ -167,7 +161,7 @@ export async function downloadUrlAs(
),
)
} else {
resolve({filename: downloadItem.filename, finalSaveAs})
resolve(downloadItem.filename)
}
} else {
reject(

View file

@ -1,29 +0,0 @@
import { messageTabChanges } from "@src/background/tab_changes"
test("tab change bursts are delivered in order without overlap", async () => {
jest.useFakeTimers()
let finishSending
const query = jest.mocked(browser.tabs.query)
const send = jest.mocked(browser.tabs.sendMessage)
query.mockResolvedValue([{ id: 1 }, { id: 2 }] as browser.tabs.Tab[])
const firstSend = new Promise<void>(resolve => (finishSending = resolve))
send.mockReturnValueOnce(firstSend).mockResolvedValue(undefined)
const waitForSends = async (count: number) => {
for (let i = 0; i < 10 && send.mock.calls.length < count; i++)
await Promise.resolve()
}
for (let id = 0; id < 1500; id++) messageTabChanges("tab_created")
jest.runOnlyPendingTimers()
await waitForSends(2)
expect(query).toHaveBeenCalledWith({ active: true })
expect(send.mock.calls.slice(0, 2)).toEqual(
[1, 2].map(id => [id, { type: "tab_changes", command: "priority" }]),
)
messageTabChanges("tab_updated")
expect(send).toHaveBeenCalledTimes(2)
finishSending()
await waitForSends(4)
expect(send.mock.calls.slice(2)).toEqual(
[1, 2].map(id => [id, { type: "tab_changes", command: "updated" }]),
)
jest.useRealTimers()
})

View file

@ -1,35 +0,0 @@
const UPDATE_PENDING = 1
const PRIORITY_PENDING = 2
let tabChanges = 0
let sendingTabChanges: Promise<void>
export function messageTabChanges(command: string) {
tabChanges |=
/^tab_(?:close|created|moved|activated|attached|detached)$/u.test(
command,
)
? PRIORITY_PENDING
: UPDATE_PENDING
sendingTabChanges ||= new Promise<void>(resolve =>
setTimeout(resolve, 0),
).then(async () => {
while (tabChanges) {
const changes = tabChanges
tabChanges = 0
const priority = changes & PRIORITY_PENDING
const tabs = await browser.tabs
.query({ active: true })
.catch(() => [])
await Promise.all(
tabs.map(tab =>
browser.tabs
.sendMessage(tab.id, {
type: "tab_changes",
command: priority ? "priority" : "updated",
})
.catch(() => undefined),
),
)
}
sendingTabChanges = undefined
})
}

View file

@ -12,41 +12,27 @@ export const requestEventExpraInfoSpecMap = {
export const requestEvents = Object.keys(requestEventExpraInfoSpecMap)
// I'm being lazy - strictly the functions map strings to void | blocking responses
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
// eslint-disable-next-line @typescript-eslint/ban-types
export const LISTENERS: Record<string, Record<string, Function>> = {}
export const registerWebRequestAutocmd = async (
export const registerWebRequestAutocmd = (
requestEvent: string,
pattern: string,
func: string,
) => {
// I'm being lazy - strictly the functions map strings to void | blocking responses
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
// eslint-disable-next-line @typescript-eslint/ban-types
const listener = eval(func) as Function
if (!LISTENERS[requestEvent]) LISTENERS[requestEvent] = {}
await browser.webRequest["on" + requestEvent].addListener(
LISTENERS[requestEvent][pattern] = listener
return browser.webRequest["on" + requestEvent].addListener(
listener,
{ urls: [pattern] },
requestEventExpraInfoSpecMap[requestEvent],
)
const oldListener = LISTENERS[requestEvent][pattern];
// Add the new listener to our list if everything was successful
LISTENERS[requestEvent][pattern] = listener
// Remove any previously registered autocmd for the same pattern
if (oldListener) {
await browser.webRequest["on" + requestEvent].removeListener(
oldListener
)
}
}
export const unregisterWebRequestAutocmd = async (requestEvent, pattern) => {
if (LISTENERS[requestEvent] && LISTENERS[requestEvent][pattern]) {
await browser.webRequest["on" + requestEvent].removeListener(
LISTENERS[requestEvent][pattern],
)
}
}
export const unregisterWebRequestAutocmd = (requestEvent, pattern) =>
browser.webRequest["on" + requestEvent].removeListener(
LISTENERS[requestEvent][pattern],
)

View file

@ -1,55 +0,0 @@
jest.mocked(browser.runtime.sendMessage)
.mockResolvedValueOnce("false")
.mockResolvedValueOnce("true")
const flush = () => new Promise(resolve => setTimeout(resolve))
test("popup separates toggling from reloading the active tab", async () => {
document.body.innerHTML = `
<p id="state"></p>
<button id="toggle" disabled></button>
<button id="reload" disabled></button>`
jest.mocked(browser.tabs.query).mockResolvedValue([
{ id: 7 } as browser.tabs.Tab,
])
jest.mocked(browser.tabs.reload).mockResolvedValue(undefined)
const close = jest.spyOn(window, "close").mockImplementation()
await import("@src/browser_action_popup")
await flush()
expect(document.querySelector("#state").textContent).toBe(
"Enabled globally",
)
expect(browser.runtime.sendMessage).toHaveBeenCalledWith({
type: "browser_action_background",
command: "getState",
args: [],
})
document.querySelector<HTMLElement>("#toggle").click()
expect(document.querySelector<HTMLButtonElement>("#reload").disabled).toBe(
true,
)
await flush()
expect(browser.runtime.sendMessage).toHaveBeenCalledWith({
type: "browser_action_background",
command: "toggle",
args: [],
})
expect(browser.tabs.reload).not.toHaveBeenCalled()
expect(document.querySelector("#state").textContent).toBe(
"Disabled globally",
)
document.querySelector<HTMLElement>("#reload").click()
expect(document.querySelector<HTMLButtonElement>("#toggle").disabled).toBe(
true,
)
await flush()
expect(browser.tabs.query).toHaveBeenCalledWith({
active: true,
currentWindow: true,
})
expect(browser.tabs.reload).toHaveBeenCalledWith(7)
expect(close).toHaveBeenCalled()
})

View file

@ -1,63 +0,0 @@
export {}
function message(command: "getState" | "toggle") {
return browser.runtime.sendMessage({
type: "browser_action_background",
command,
args: [],
})
}
const state = document.querySelector<HTMLElement>("#state")
const toggleButton = document.querySelector<HTMLButtonElement>("#toggle")
const reloadButton = document.querySelector<HTMLButtonElement>("#reload")
function showState(value) {
const disabled = value === "true"
state.textContent = disabled ? "Disabled globally" : "Enabled globally"
toggleButton.textContent = disabled
? "Enable Tridactyl"
: "Disable Tridactyl"
}
function showError(error) {
state.textContent = `Error: ${error instanceof Error ? error.message : error}`
}
function setBusy(busy) {
toggleButton.disabled = busy
reloadButton.disabled = busy
}
async function run(action: () => Promise<void>) {
setBusy(true)
try {
await action()
} catch (error) {
showError(error)
} finally {
setBusy(false)
}
}
message("getState")
.then(showState)
.then(() => setBusy(false), showError)
toggleButton.addEventListener("click", () =>
run(async () => {
showState(await message("toggle"))
}),
)
reloadButton.addEventListener("click", () =>
run(async () => {
const [tab] = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (tab?.id === undefined) throw new Error("No active tab found")
await browser.tabs.reload(tab.id)
window.close()
}),
)

View file

@ -12,7 +12,6 @@
*
* Contrary to the main tridactyl help page, this one doesn't tell you whether a specific function is bound to something. For now, you'll have to make do with `:bind` and `:viewconfig`.
*
* @packageDocumentation
*/
/** ignore this line */
@ -23,20 +22,13 @@ import { CompletionSourceFuse } from "@src/completions"
import { AproposCompletionSource } from "@src/completions/Apropos"
import { AutocmdCompletionSource } from "@src/completions/Autocmd"
import { BindingsCompletionSource } from "@src/completions/Bindings"
import {
BmarkCompletionSource,
BookmarkFolderCompletionSource,
} from "@src/completions/Bmark"
import { BmarkCompletionSource } from "@src/completions/Bmark"
import { CompositeCompletionSource } from "@src/completions/Composite"
import { ContainerCompletionSource } from "@src/completions/Container"
import { DialogCompletionSource } from "@src/completions/Dialog"
import { ExcmdCompletionSource } from "@src/completions/Excmd"
import { ExtensionsCompletionSource } from "@src/completions/Extensions"
import { FileSystemCompletionSource } from "@src/completions/FileSystem"
import { FindCompletionSource } from "@src/completions/Find"
import { GotoCompletionSource } from "@src/completions/Goto"
import { GuisetCompletionSource } from "@src/completions/Guiset"
import { GlossaryCompletionSource } from "@src/completions/Glossary"
import { HelpCompletionSource } from "@src/completions/Help"
import { HistoryCompletionSource } from "@src/completions/History"
import { PreferenceCompletionSource } from "@src/completions/Preferences"
@ -51,7 +43,7 @@ import { WindowCompletionSource } from "@src/completions/Window"
import { ProxyCompletionSource } from "@src/completions/Proxy"
import { contentState } from "@src/content/state_content"
import { theme } from "@src/content/styling"
import { expandAbbreviation, getCommandlineFns } from "@src/lib/commandline_cmds"
import { getCommandlineFns } from "@src/lib/commandline_cmds"
import * as tri_editor from "@src/lib/editor"
import "@src/lib/DANGEROUS-html-tagged-template"
import Logger from "@src/lib/logging"
@ -61,13 +53,8 @@ import * as genericParser from "@src/parsers/genericmode"
import * as perf from "@src/perf"
import state, * as State from "@src/state"
import * as R from "ramda"
import {
MinimalKey,
minimalKeyFromKeyboardEvent,
isTrustedKeyboardEvent,
} from "@src/lib/keyseq"
import { MinimalKey, minimalKeyFromKeyboardEvent } from "@src/lib/keyseq"
import { TabGroupCompletionSource } from "@src/completions/TabGroup"
import { ProfileCompletionSource } from "@src/completions/Profile"
/** @hidden **/
const logger = new Logger("cmdline")
@ -83,11 +70,6 @@ const commandline_state = {
completionsDiv: window.document.getElementById("completions"),
fns: undefined as ReturnType<typeof getCommandlineFns>,
getCompletion,
getCompletions: () =>
(commandline_state.activeCompletions || []).flatMap(source =>
source.visibleCompletions(),
),
getActiveCompletionSource,
history,
/** @hidden
* This is to handle Escape key which, while the cmdline is focused,
@ -100,7 +82,6 @@ const commandline_state = {
keyEvents: new Array<MinimalKey>(),
initialClInputValue: "",
refresh_completions,
resolveCloseWaiters,
state,
}
@ -114,49 +95,39 @@ function resizeArea() {
focus()
}
}
window.addEventListener("tridactyl-refresh-completions", resizeArea)
/** @hidden
* This is a bit loosely defined at the moment.
* Should work so long as there's only one completion source per prefix.
*/
function getActiveCompletionSource(): CompletionSourceFuse | undefined {
function getCompletion(args_only = false) {
if (!commandline_state.activeCompletions) return undefined
return commandline_state.activeCompletions.filter(
({ state, completion }) =>
state === "normal" && completion !== undefined,
)[0]
}
/** @hidden **/
function getCompletion(args_only = false): string | undefined {
const activeSource = getActiveCompletionSource()
if (!activeSource) return undefined
return args_only ? activeSource.args : activeSource.completion
for (const comp of commandline_state.activeCompletions) {
if (comp.state === "normal" && comp.completion !== undefined) {
return args_only ? comp.args : comp.completion
}
}
}
commandline_state.getCompletion = getCompletion
/** @hidden **/
export function enableCompletions() {
if (!commandline_state.activeCompletions) {
commandline_state.activeCompletions = [
AutocmdCompletionSource,
FindCompletionSource,
// FindCompletionSource,
BindingsCompletionSource,
BmarkCompletionSource,
BookmarkFolderCompletionSource,
TabAllCompletionSource,
BufferCompletionSource,
ExcmdCompletionSource,
ThemeCompletionSource,
TabHistoryCompletionSource,
CompositeCompletionSource,
ContainerCompletionSource,
DialogCompletionSource,
FileSystemCompletionSource,
GotoCompletionSource,
GuisetCompletionSource,
GlossaryCompletionSource,
HelpCompletionSource,
AproposCompletionSource,
HistoryCompletionSource,
@ -165,7 +136,6 @@ export function enableCompletions() {
SessionsCompletionSource,
SettingsCompletionSource,
TabGroupCompletionSource,
ProfileCompletionSource,
WindowCompletionSource,
ExtensionsCompletionSource,
ProxyCompletionSource,
@ -190,44 +160,24 @@ export function enableCompletions() {
/** @hidden **/
const noblur = () => setTimeout(() => commandline_state.clInput.focus(), 0)
/** @hidden **/
const closeWaiters: (() => void)[] = []
/** @hidden **/
function waitForClose() {
return new Promise<void>(resolve => closeWaiters.push(resolve))
}
/** @hidden **/
function resolveCloseWaiters() {
closeWaiters.splice(0).forEach(resolve => resolve())
}
/** @hidden **/
export function focus() {
function consumeBufferedPageKeys(bufferedPageKeys: string[]) {
const clInputStillFocused =
window.document.activeElement === commandline_state.clInput
logger.debug(
"stop_buffering_page_keys response received, bufferedPageKeys = ",
bufferedPageKeys,
"clInputStillFocused = " + clInputStillFocused,
)
const clInputStillFocused = window.document.activeElement === commandline_state.clInput;
logger.debug("stop_buffering_page_keys response received, bufferedPageKeys = ", bufferedPageKeys,
"clInputStillFocused = " + clInputStillFocused)
if (bufferedPageKeys.length !== 0) {
const currentClInputValue = commandline_state.clInput.value
const initialClInputValue = commandline_state.initialClInputValue
logger.debug(
"Consuming buffered page keys",
bufferedPageKeys,
const currentClInputValue = commandline_state.clInput.value;
const initialClInputValue = commandline_state.initialClInputValue;
logger.debug("Consuming buffered page keys", bufferedPageKeys,
"initialClInputValue = " + initialClInputValue,
"currentClInputValue = " + currentClInputValue,
)
"currentClInputValue = " + currentClInputValue);
// Native events are assumed to be character keydown events,
// i.e. characters appended at the end of clInput.
commandline_state.clInput.value =
initialClInputValue +
bufferedPageKeys.join("") +
currentClInputValue.substring(initialClInputValue.length)
initialClInputValue
+ bufferedPageKeys.join("")
+ currentClInputValue.substring(initialClInputValue.length)
// Update completion.
clInputValueChanged()
}
@ -235,13 +185,8 @@ export function focus() {
commandline_state.clInput.focus()
commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.addEventListener("blur", noblur)
logger.debug(
"commandline_frame clInput focus(), activeElement is clInput: " +
(window.document.activeElement === commandline_state.clInput),
)
Messaging.messageOwnTab("stop_buffering_page_keys").then(
consumeBufferedPageKeys,
)
logger.debug("commandline_frame clInput focus(), activeElement is clInput: " + (window.document.activeElement === commandline_state.clInput))
Messaging.messageOwnTab("stop_buffering_page_keys").then(consumeBufferedPageKeys)
}
/** @hidden **/
@ -250,45 +195,25 @@ let HISTORY_SEARCH_STRING: string
/** @hidden
* Command line keybindings
**/
const keyParser = keys => genericParser.parser("exmaps", keys, false)
const keyParser = keys => genericParser.parser("exmaps", keys)
/** @hidden **/
let history_called = false
/** @hidden **/
let prev_cmd_called_history = false
let commandSession = { pending: 0, queue: [Promise.resolve()] }
const nativeInsertFallbacks = new Map<object, () => boolean>()
// Save programmer time by generating an immediately resolved promise
// eslint-disable-next-line @typescript-eslint/no-empty-function
const QUEUE: Promise<any>[] = [(async () => {})()]
/** @hidden **/
commandline_state.clInput.addEventListener(
"keydown",
function (keyevent: Event) {
if (!isTrustedKeyboardEvent(keyevent)) return
logger.debug(
"commandline_frame clInput keydown event listener",
keyevent,
)
const session = commandSession
function (keyevent: KeyboardEvent) {
if (!keyevent.isTrusted) return
logger.debug("commandline_frame clInput keydown event listener", keyevent)
commandline_state.keyEvents.push(minimalKeyFromKeyboardEvent(keyevent))
const response = keyParser(commandline_state.keyEvents)
const [funcname, ...args] = response.value?.startsWith("ex.")
? response.value.slice(3).split(/\s+/)
: []
const command =
commandline_state.fns[funcname as keyof typeof commandline_state.fns]
const nativeInsertFallback = nativeInsertFallbacks.get(command)
const commandArgument = args.length
? args.join(" ")
: nativeInsertFallback && keyevent.key.length === 1
? keyevent.key
: undefined
const insertCharacterNatively =
args.length === 0 &&
keyevent.key.length === 1 &&
!(keyevent.altKey || keyevent.ctrlKey || keyevent.metaKey) &&
session.pending === 0 &&
nativeInsertFallback?.()
if (response.isMatch && !insertCharacterNatively) {
if (response.isMatch) {
keyevent.preventDefault()
keyevent.stopImmediatePropagation()
} else {
@ -302,24 +227,25 @@ commandline_state.clInput.addEventListener(
if (response.value) {
commandline_state.keyEvents = []
history_called = false
if (insertCharacterNatively) return
// If excmds start with 'ex.' they're coming back to us anyway, so skip that.
// This is definitely a hack. Should expand aliases with exmode, etc.
// but this whole thing should be scrapped soon, so whatever.
if (funcname) {
session.pending++
if (response.value.startsWith("ex.")) {
const [funcname, ...args] = response.value.slice(3).split(/\s+/)
session.queue[session.queue.length - 1].then(() => {
session.queue.push(
QUEUE[QUEUE.length - 1].then(() => {
QUEUE.push(
// Abuse async to wrap non-promises in a promise
// eslint-disable-next-line @typescript-eslint/require-await
(async () => session === commandSession && command(commandArgument))()
.catch(error => void logger.error(error))
.finally(() => session.pending--),
(async () =>
commandline_state.fns[
funcname as keyof typeof commandline_state.fns
](
args.length === 0 ? undefined : args.join(" "),
))(),
)
if (session === commandSession)
prev_cmd_called_history = history_called
prev_cmd_called_history = history_called
})
} else {
// Send excmds directly to our own tab, which fixes the
@ -332,7 +258,6 @@ commandline_state.clInput.addEventListener(
// to be a touch less latency-sensitive.
Messaging.messageOwnTab("controller_content", "acceptExCmd", [
response.value,
"commandline",
]).then(_ => (prev_cmd_called_history = history_called))
}
} else {
@ -342,80 +267,50 @@ commandline_state.clInput.addEventListener(
true,
)
let refreshQueue: Promise<unknown> = Promise.resolve()
export function refresh_completions(exstr) {
const session = commandSession
const result = refreshQueue.then(() =>
session === commandSession ? refreshCompletions(exstr) : undefined,
)
refreshQueue = result.catch(() => undefined)
return result
}
function refreshCompletions(exstr) {
if (!commandline_state.activeCompletions) enableCompletions()
// We can't use the regular logging mechanism because the user is using the command line.
return Promise.all(
commandline_state.activeCompletions.map(comp =>
comp
.filter(exstr)
.then(() => {
if (comp.shouldRefresh()) {
return resizeArea()
}
})
.catch(err => console.error(err)),
comp.filter(exstr).then(() => {
if (comp.shouldRefresh()) {
return resizeArea()
}
}),
),
)
).catch(err => {
console.error(err)
return []
}) // We can't use the regular logging mechanism because the user is using the command line.
}
/** @hidden **/
let onInputPromise: Promise<void | void[]> = Promise.resolve()
const COMPLETION_THROTTLE_MS = 100
let completionTimer
let lastCompletionStarted = -Infinity
let onInputPromise: Promise<any> = Promise.resolve()
/** @hidden **/
commandline_state.clInput.addEventListener("input", () => {
logger.debug("commandline_frame clInput input event listener")
clInputValueChanged()
clInputValueChanged();
})
/** @hidden **/
async function updateCompletions(exstr: string, session = commandSession) {
lastCompletionStarted = performance.now()
await onInputPromise
if (session !== commandSession) return
if (exstr !== commandline_state.clInput.value) {
contentState.cmdline_filter = exstr
return
}
onInputPromise = refresh_completions(exstr)
onInputPromise.then(() => {
contentState.cmdline_filter = exstr
})
}
/** @hidden **/
function clInputValueChanged() {
const exstr = commandline_state.clInput.value
const session = commandSession
contentState.current_cmdline = exstr
contentState.cmdline_filter = ""
// Run immediately when idle, otherwise retain one trailing refresh.
clearTimeout(completionTimer)
const delay =
COMPLETION_THROTTLE_MS - (performance.now() - lastCompletionStarted)
if (delay <= 0) void updateCompletions(exstr, session)
else
completionTimer = setTimeout(
() =>
void updateCompletions(
commandline_state.clInput.value,
session,
),
delay,
)
// Schedule completion computation. We do not start computing immediately because this would incur a slow down on quickly repeated input events (e.g. maintaining <Backspace> pressed)
setTimeout(async () => {
// Make sure the previous computation has ended
await onInputPromise
// If we're not the current completion computation anymore, stop
if (exstr !== commandline_state.clInput.value) {
contentState.cmdline_filter = exstr
return
}
onInputPromise = refresh_completions(exstr)
onInputPromise.then(() => {
contentState.cmdline_filter = exstr
})
}, 100)
}
/** @hidden **/
@ -427,12 +322,6 @@ let cmdline_history_current = ""
* Otherwise, no need to pass an argument.
*/
export function clear(evlistener = false) {
if (evlistener) {
commandSession = { pending: 0, queue: [Promise.resolve()] }
clearTimeout(completionTimer)
lastCompletionStarted = -Infinity
}
if (evlistener) prev_cmd_called_history = false
if (evlistener)
commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.value = ""
@ -463,7 +352,6 @@ async function history(n) {
const pot_history = matches[clamped_ind]
commandline_state.clInput.value =
pot_history === undefined ? cmdline_history_current : pot_history
clInputValueChanged()
// if there was no clampage, update history position
// there's a more sensible way of doing this but that would require more programmer time
@ -481,29 +369,19 @@ export function fillcmdline(
newcommand?: string,
trailspace = true,
ffocus = true,
wait = false,
) {
logger.debug(
"commandline_frame fillcmdline(newcommand = " +
newcommand +
" trailspace = " +
trailspace +
" ffocus = " +
ffocus +
")",
)
logger.debug("commandline_frame fillcmdline(newcommand = " + newcommand + " trailspace = " + trailspace + " ffocus = " + ffocus + ")")
if (trailspace) commandline_state.clInput.value = newcommand + " "
else commandline_state.clInput.value = newcommand
commandline_state.initialClInputValue = commandline_state.clInput.value
commandline_state.isVisible = true
const closed = wait ? waitForClose() : undefined
let result = Promise.resolve([])
// Focus is lost for some reason.
if (ffocus) {
focus()
result = refresh_completions(commandline_state.clInput.value)
}
return wait ? result.then(() => closed) : result
return result
}
/** @hidden **/
@ -528,17 +406,7 @@ export function editor_function(fn_name: keyof typeof tri_editor, ...args) {
Messaging.addListener("commandline_frame", Messaging.attributeCaller(SELF))
logger.debug("Added commandline_frame message listener")
/** @namespace */
export const commandlineFns = getCommandlineFns(commandline_state)
commandline_state.fns = commandlineFns
nativeInsertFallbacks.set(
commandline_state.fns.insert_character_or_completion,
() => {
if (getCompletion()) return false
expandAbbreviation(commandline_state.clInput)
return true
},
)
commandline_state.fns = getCommandlineFns(commandline_state)
Messaging.addListener(
"commandline_cmd",
Messaging.attributeCaller(commandline_state.fns),
@ -549,8 +417,8 @@ Messaging.addListener(
// object since there's apparently a bug that causes performance
// observers to be GC'd even if they're still the target of a
// callback.
window["tri"] = Object.assign(window.tri || {}, {
;(window as any).tri = Object.assign(window.tri || {}, {
perfObserver: perf.listenForCounters(),
})
Messaging.messageOwnTab("commandline_frame_ready_to_receive_messages", window.name)
Messaging.messageOwnTab("commandline_frame_ready_to_receive_messages")

View file

@ -11,14 +11,11 @@ Concrete completion classes have been moved to src/completions/.
*/
import Fuse from "fuse.js"
import { enumerate } from "@src/lib/itertools"
import { toNumber } from "@src/lib/convert"
import * as aliases from "@src/lib/aliases"
import { backoff } from "@src/lib/patience"
import * as config from "@src/lib/config"
export { decodeUrlForDisplay } from "@src/lib/url_util"
export function treePrefix(level: number) {
return ` ${"".repeat(Math.max(level - 1, 0))}${level ? "┌─" : ""}· `
}
export const DEFAULT_FAVICON = browser.runtime.getURL(
"static/defaultFavicon.svg",
@ -36,17 +33,16 @@ export abstract class CompletionOption {
}
export abstract class CompletionSource {
options: CompletionOption[]
readonly options: CompletionOption[]
node: HTMLElement
public completion: string
public args: string
public trailingSpace: boolean
protected prefixes: string[] = []
protected lastFocused: CompletionOption
private _state: OptionState
private _prevState: OptionState
constructor(prefixes, options = { trailingSpace: true }) {
constructor(prefixes) {
const commands = aliases.getCmdAliasMapping()
// Now, for each prefix given as argument, add it to the completionsource's prefix list and also add any alias it has
@ -60,11 +56,6 @@ export abstract class CompletionSource {
// Not sure this is necessary but every completion source has it
this.prefixes = this.prefixes.map(p => p + " ")
this.trailingSpace = options.trailingSpace
}
protected canonicalisePrefix(prefix: string) {
return aliases.expandExstr(prefix).trim()
}
/** Control presentation of Source */
@ -154,13 +145,14 @@ export interface CompletionOptionFuse extends CompletionOptionHTML {
}
export interface ScoredOption {
index: number
option: CompletionOptionFuse
score: number
}
export abstract class CompletionSourceFuse extends CompletionSource {
options: CompletionOptionFuse[]
public node
public options: CompletionOptionFuse[]
fuseOptions = {
keys: ["fuseKeys"],
@ -182,22 +174,11 @@ export abstract class CompletionSourceFuse extends CompletionSource {
protected optionContainer = html`<table class="optionContainer"></table>`
private fusedOptions: CompletionOptionFuse[]
constructor(
prefixes,
className: string,
title?: string | HTMLElement,
options = { trailingSpace: true },
) {
super(prefixes, options)
this.node = html`<div class="${className} hidden"></div>`
const header =
typeof title === "string" || title === undefined
? html`<div>${title || className}</div>`
: title
header.classList.add("sectionHeader")
this.node.appendChild(header)
constructor(prefixes, className: string, title?: string) {
super(prefixes)
this.node = html`<div class="${className} hidden">
<div class="sectionHeader">${title || className}</div>
</div>`
this.node.appendChild(this.optionContainer)
this.state = "hidden"
}
@ -243,18 +224,6 @@ export abstract class CompletionSourceFuse extends CompletionSource {
this.updateDisplay()
}
completionForOption(option: CompletionOption) {
const [prefix] = this.splitOnPrefix(this.lastExstr)
return prefix ? [prefix, option.value].join(" ") : option.value
}
visibleCompletions() {
if (this.state === "hidden") return []
return (this.options || [])
.filter(option => option.state !== "hidden")
.map(option => this.completionForOption(option))
}
select(option: CompletionOption) {
if (this.lastExstr !== undefined && option !== undefined) {
const [prefix] = this.splitOnPrefix(this.lastExstr)
@ -279,15 +248,20 @@ export abstract class CompletionSourceFuse extends CompletionSource {
/** Rtn sorted array of {option, score} */
scoredOptions(query: string): ScoredOption[] {
if (this.fuse === undefined || this.fusedOptions !== this.options) {
this.fuse = new Fuse(this.options, this.fuseOptions)
this.fusedOptions = this.options
}
return this.fuse.search(query).map(result => ({
option: result.item,
score: result.score,
const searchThis = this.options.map((elem, index) => ({
index,
fuseKeys: elem.fuseKeys,
}))
this.fuse = new Fuse(searchThis, this.fuseOptions)
return this.fuse.search(query).map(result => {
// console.log(result, result.item, query)
const index = toNumber(result.item.index)
return {
index,
option: this.options[index],
score: result.score as number,
}
})
}
/** Set option state by score
@ -296,35 +270,44 @@ export abstract class CompletionSourceFuse extends CompletionSource {
focus the best match.
*/
setStateFromScore(scoredOpts: ScoredOption[], autoselect = false) {
const matches = new Set(scoredOpts.map(res => res.option))
const matches = scoredOpts.map(res => res.index)
const hidden_options = []
for (const option of this.options) {
if (matches.has(option)) {
option.state = "normal"
} else {
for (const [index, option] of enumerate(this.options)) {
if (matches.includes(index)) option.state = "normal"
else {
option.state = "hidden"
hidden_options.push(option)
}
}
if (scoredOpts.length && autoselect) {
this.select(scoredOpts[0].option)
// ideally, this would not deselect anything unless it fell off the list of matches
if (matches.length && autoselect) {
this.select(this.options[matches[0]])
} else {
this.deselect()
}
// sort this.options by score
if (this.sortScoredOptions) {
const sorted_options = scoredOpts.map(res => res.option)
const sorted_options = matches.map(index => this.options[index])
this.options = sorted_options.concat(hidden_options)
this.fusedOptions = this.options
}
}
/** Call to replace the current display */
updateDisplay() {
const visibleOptions = this.options.filter(o => o.state !== "hidden").map(o => o.html)
this.optionContainer.replaceChildren(...visibleOptions)
const newContainer = this.optionContainer.cloneNode(
false,
) as HTMLElement
for (const option of this.options) {
if (option.state !== "hidden")
// This is probably slow: `.html` means the HTML parser will be invoked
newContainer.appendChild(option.html)
}
this.optionContainer.replaceWith(newContainer)
this.optionContainer = newContainer
this.next(0)
}
@ -339,7 +322,7 @@ export abstract class CompletionSourceFuse extends CompletionSource {
this.deselect()
// visopts.length + 1 because we want an empty completion at the end
const max = visopts.length + 1
const opt = visopts[((currind + inc) % max + max) % max]
const opt = visopts[(currind + inc + max) % max]
if (opt) this.select(opt)
return true
})
@ -349,7 +332,7 @@ export abstract class CompletionSourceFuse extends CompletionSource {
/* abstract onUpdate(query: string, prefix: string, options: CompletionOptionFuse[]) */
// Lots of methods don't need this but some do
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars-experimental
async onInput(exstr: string) {}
}

View file

@ -1,22 +1,13 @@
import * as Completions from "@src/completions"
import {
excmdsFunctions,
defaultConfigMembers,
getDoc,
memberDoc,
} from "@src/.metadata.generated"
import * as Metadata from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
import { glossaryOptions } from "@src/completions/Glossary"
class AproposCompletionOption extends Completions.CompletionOptionHTML implements Completions.CompletionOptionFuse {
class AproposCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(
public name: string,
doc: string,
flag: string,
) {
constructor(public name: string, doc: string, flag: string) {
super()
this.value = `${flag} ${name}`
this.html = html`<tr class="AproposCompletionOption option">
@ -29,21 +20,12 @@ class AproposCompletionOption extends Completions.CompletionOptionHTML implement
export class AproposCompletionSource extends Completions.CompletionSourceFuse {
public options: AproposCompletionOption[]
constructor(
private _parent,
prefixes = ["apropos"],
className = "AproposCompletionSource",
title = "Apropos",
) {
super(prefixes, className, title)
constructor(private _parent) {
super(["apropos"], "AproposCompletionSource", "Apropos")
this._parent.appendChild(this.node)
}
protected createOption(name: string, doc: string, flag: string) {
return new AproposCompletionOption(name, doc, flag)
}
public async filter(exstr: string) {
this.lastExstr = exstr
this.completion = undefined
@ -60,13 +42,20 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
return
}
const file = Metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const excmds = Metadata.everything.getFile("src/excmds.ts")
const fns = excmds.getFunctions()
const settings = config.get()
const exaliases = settings.exaliases
const bindings = settings.nmaps
if (exaliases === undefined || bindings === undefined) {
if (
fns === undefined ||
exaliases === undefined ||
bindings === undefined
) {
return
}
const fns = Object.entries(excmdsFunctions)
const flags = {
"-a": (options, query) =>
@ -76,15 +65,17 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
(
alias +
aliases.expandExstr(alias) +
excmdsFunctions[aliases.expandExstr(alias)]
excmds.getFunction(aliases.expandExstr(alias))
)
.toLowerCase()
.includes(query),
)
.map(alias => {
const cmd = aliases.expandExstr(alias)
const doc = getDoc(excmdsFunctions[cmd])
return this.createOption(
const doc =
(excmds.getFunction(cmd) || ({} as any)).doc ||
""
return new AproposCompletionOption(
alias,
`Alias for \`${cmd}\`. ${doc}`,
"-a",
@ -101,7 +92,7 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
)
.map(
binding =>
this.createOption(
new AproposCompletionOption(
binding,
`Normal mode binding for \`${bindings[binding]}\``,
"-b",
@ -111,14 +102,16 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
"-e": (options, query) =>
options.concat(
fns
.filter(([name, fn]) =>
(name + getDoc(fn)).toLowerCase().includes(query),
.filter(
([name, fn]) =>
!fn.hidden &&
(name + fn.doc).toLowerCase().includes(query),
)
.map(
([name, fn]) =>
this.createOption(
new AproposCompletionOption(
name,
`Excmd. ${getDoc(fn)}`,
`Excmd. ${fn.doc}`,
"-e",
),
),
@ -127,23 +120,23 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
options.concat(
Object.keys(settings)
.filter(x =>
(x + memberDoc(defaultConfigMembers[x]))
(x + default_config.getMember(x)?.doc)
.toLowerCase()
.includes(query),
)
.map(setting => {
const doc = memberDoc(defaultConfigMembers[setting])
return this.createOption(
const member = default_config.getMember(setting)
let doc = ""
if (member !== undefined) {
doc = member.doc
}
return new AproposCompletionOption(
setting,
`Setting. ${doc}`,
"-s",
)
}),
),
"-g": (options, query) =>
options.concat(
glossaryOptions(this.createOption.bind(this), query, false),
),
}
const args = query.split(" ")

View file

@ -25,7 +25,7 @@ export class AutocmdCompletionSource extends Completions.CompletionSourceFuse {
constructor(private _parent) {
super(
["autocmd", "autocmddelete", "autocontaindelete"],
["autocmd", "autocmddelete"],
"AutocmdCompletionSource",
"Autocommands",
)
@ -59,16 +59,7 @@ export class AutocmdCompletionSource extends Completions.CompletionSourceFuse {
this.state = "hidden"
return
}
const command = this.canonicalisePrefix(prefix)
if (command === "autocontaindelete") {
this.options = Object.entries(config.get("autocontain"))
.sort(([a], [b]) => a.localeCompare(b))
.map(([pattern, container]) =>
new AutocmdCompletionOption(pattern, container, `-u ${pattern}`),
)
return this.updateChain()
}
const is_autocmddelete = command === "autocmddelete"
const is_autocmddelete = /del/.test(prefix)
const filter_defined_autocmds = is_autocmddelete
const defined_autocmds = config.get("autocmds")
// Config may contain empty dictionnaries if user deleted all patterns

View file

@ -2,17 +2,6 @@ import * as Completions from "@src/completions"
import * as config from "@src/lib/config"
import * as Binding from "@src/lib/binding"
const modeDescriptions = new Map([
["normal", "Default mode"],
["ignore", "Almost all keys passed through to web pages except these binds"],
["insert", "Editing text"],
["input", "Bindings after `gi` focuses a text field"],
["ex", "Command line binds"],
["hint", "Binds accessible during hint mode"],
["visual", "Text selection outside insert mode"],
["browser", "Binds accessible everywhere, including while pages load. NB: some caveats, see :help bind"],
])
class BindingsCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
@ -62,7 +51,6 @@ export class BindingsCompletionSource extends Completions.CompletionSourceFuse {
this.state = "hidden"
return
}
prefix = this.canonicalisePrefix(prefix)
this.deselect()
@ -102,7 +90,7 @@ export class BindingsCompletionSource extends Completions.CompletionSourceFuse {
options + "--mode=" + name,
{
name,
value: modeDescriptions.get(name),
value: "",
mode: "Mode Name",
},
),

View file

@ -9,7 +9,7 @@ class BmarkCompletionOption
constructor(
public value: string,
bmark: providers.Bookmark,
bmark: browser.bookmarks.BookmarkTreeNode,
) {
super()
if (!bmark.title) {
@ -17,14 +17,14 @@ class BmarkCompletionOption
}
// Push properties we want to fuzmatch on
this.fuseKeys.push(bmark.path, bmark.title, bmark.url)
this.fuseKeys.push(bmark.title, bmark.url)
this.html = html`<tr class="BmarkCompletionOption option">
<td class="prefix">${"".padEnd(2)}</td>
<td class="title">${bmark.path}${bmark.title}</td>
<td class="title">${bmark.title}</td>
<td class="content">
<a class="url" target="_blank" href=${bmark.url}
>${Completions.decodeUrlForDisplay(bmark.url)}</a
>${bmark.url}</a
>
</td>
</tr>`
@ -70,11 +70,6 @@ export class BmarkCompletionSource extends Completions.CompletionSourceFuse {
option += " "
query = args.slice(2).join(" ")
}
if (query.startsWith("-b")) {
const args = query.split(" ")
option += args.slice(0, 1).join(" ") + " "
query = args.slice(1).join(" ")
}
this.completion = undefined
this.options = (await providers.getBookmarks(query))
@ -101,57 +96,14 @@ export class BmarkCompletionSource extends Completions.CompletionSourceFuse {
// Call concrete class
return this.updateDisplay()
}
}
export class BookmarkFolderCompletionSource extends Completions.CompletionSourceFuse {
constructor() {
super(["bmark"], "BookmarkFolderCompletionSource", "Bookmark Folders", {
trailingSpace: false,
})
}
async onInput(exstr: string) {
const [_command, _url, path] = this.parseArgs(exstr)
if (path == undefined) {
this.options = undefined
return
select(option: Completions.CompletionOption) {
if (this.lastExstr !== undefined && option !== undefined) {
this.completion = "bmarks " + option.value
option.state = "focused"
this.lastFocused = option
} else {
throw new Error("lastExstr and option must be defined!")
}
this.options = (await providers.getBookmarkFolders(path))
.slice(0, 10)
.map(path => new BookmarkFolderCompletionOption(path))
}
splitOnPrefix(exstr: string): string[] {
const [command, url, path] = this.parseArgs(exstr)
return [`${command} ${url}`, path]
}
private parseArgs(exstr: string): string[] {
const [command, args] = super.splitOnPrefix(exstr)
if (!args) {
return [command]
}
const spaceIndex = args.search(/\s+/)
const url = args.slice(0, spaceIndex)
if (spaceIndex == -1) {
return [command, url]
}
const path = args.slice(spaceIndex + 1)
return [command, url, path]
}
}
class BookmarkFolderCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
fuseKeys = []
constructor(public value: string) {
super()
this.fuseKeys.push(value)
this.html = html`<tr class="BookmarkFolderCompletionOption option">
<td class="prefix">${"".padEnd(2)}</td>
<td class="title">${value}</td>
</tr>`
}
}

View file

@ -1,10 +1,11 @@
import * as Completions from "@src/completions"
import * as ExcmdCompletions from "@src/completions/Excmd"
import { excmdsFunctions, getDoc } from "@src/.metadata.generated"
import * as Metadata from "@src/.metadata.generated"
import * as config from "@src/lib/config"
import * as aliases from "@src/lib/aliases"
const PREFIX = "composite"
const regex = new RegExp("^" + PREFIX + " ")
// Most of this is copied verbatim from Excmd.ts - would have liked to inherit but constructor posed difficulties
export class CompositeCompletionSource extends Completions.CompletionSourceFuse {
@ -26,7 +27,7 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
return this.updateOptions(exstr)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental
updateChain(exstr = this.lastExstr, options = this.options) {
if (this.options.length > 0) this.state = "normal"
else this.state = "hidden"
@ -34,15 +35,6 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
this.updateDisplay()
}
completionForOption(option: ExcmdCompletions.ExcmdCompletionOption) {
return (
this.lastExstr.replace(
new RegExp(this.getendexstr(this.lastExstr) + "$"),
"",
) + option.value
)
}
select(option: ExcmdCompletions.ExcmdCompletionOption) {
this.completion =
this.lastExstr.replace(
@ -73,15 +65,21 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
return
}
const excmds = Metadata.everything.getFile("src/excmds.ts")
if (!excmds) return
const fns = excmds.getFunctions()
// Add all excmds that start with exstr and that tridactyl has metadata about to completions
this.options = this.scoreOptions(
Object.entries(excmdsFunctions)
.filter(([name]) => name.startsWith(end_exstr))
fns
.filter(
([name, fn]) => !fn.hidden && name.startsWith(end_exstr),
)
.map(
([name, fn]) =>
new ExcmdCompletions.ExcmdCompletionOption(
name,
getDoc(fn),
fn.doc,
),
),
)
@ -92,12 +90,12 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
)
for (const alias of exaliases) {
const cmd = aliases.expandExstr(alias)
const fn = excmdsFunctions[cmd]
const fn = excmds.getFunction(cmd)
if (fn) {
this.options.push(
new ExcmdCompletions.ExcmdCompletionOption(
alias,
`Alias for \`${cmd}\`. ${getDoc(fn)}`,
`Alias for \`${cmd}\`. ${fn.doc}`,
),
)
} else {
@ -120,8 +118,8 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
}
private getendexstr(exstr) {
const [, query = exstr] = this.splitOnPrefix(exstr)
return query
return exstr
.replace(regex, "")
.split("|")
.slice(-1)[0]
.split(";")

View file

@ -1,37 +0,0 @@
import * as Completions from "@src/completions"
import * as Containers from "@src/lib/containers"
class ContainerCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(public value: string) {
super()
this.fuseKeys.push(value)
this.html = html`<tr class="ContainerCompletionOption option">
<td class="title">${value}</td>
</tr>`
}
}
export class ContainerCompletionSource extends Completions.CompletionSourceFuse {
public options: ContainerCompletionOption[]
constructor(private _parent) {
super(["recontain", "containerclose", "containerdelete", "containerupdate"], "ContainerCompletionSource", "Containers")
this._parent.appendChild(this.node)
}
async onInput(exstr: string) {
const [prefix, query] = this.splitOnPrefix(exstr)
if (!prefix) return
const command = this.canonicalisePrefix(prefix)
this.options =
command === "containerupdate" && /\s/u.test(query)
? undefined
: (await Containers.getAll())
.filter(container => command !== "containerupdate" || !/\s/u.test(container.name))
.map(container => new ContainerCompletionOption(container.name))
}
}

View file

@ -1,30 +0,0 @@
import * as Completions from "@src/completions"
import { ABOUT_PAGES } from "@src/lib/about_pages"
import "@src/lib/DANGEROUS-html-tagged-template"
class DialogCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys: string[]
constructor(public value: string, description: string) {
super()
this.fuseKeys = [value, description]
this.html = html`<tr class="DialogCompletionOption option">
<td class="title">${value}</td>
<td class="description"></td>
</tr>`
this.html.querySelector(".description").textContent = description
}
}
export class DialogCompletionSource extends Completions.CompletionSourceFuse {
public options = Object.entries(ABOUT_PAGES).map(
([page, description]) => new DialogCompletionOption(page, description),
)
constructor(parent: HTMLElement) {
super(["dialog"], "DialogCompletionSource", "Firefox about pages")
parent.appendChild(this.node)
}
}

View file

@ -1,16 +1,14 @@
import * as Completions from "@src/completions"
import { excmdsFunctions, getDoc } from "@src/.metadata.generated"
import * as Metadata from "@src/.metadata.generated"
import * as config from "@src/lib/config"
import * as aliases from "@src/lib/aliases"
export class ExcmdCompletionOption extends Completions.CompletionOptionHTML implements Completions.CompletionOptionFuse {
export class ExcmdCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(
public value: string,
public documentation = "",
) {
constructor(public value: string, public documentation: string = "") {
super()
this.fuseKeys.push(this.value, this.documentation)
this.fuseKeys.push(this.value)
// Create HTMLElement
this.html = html`<tr class="ExcmdCompletionOption option">
@ -39,7 +37,7 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
return this.updateOptions(exstr)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental
updateChain(exstr = this.lastExstr, options = this.options) {
if (this.options.length > 0) this.state = "normal"
else this.state = "hidden"
@ -59,15 +57,16 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
private async updateOptions(exstr = "") {
this.lastExstr = exstr
this.node.querySelector(".sectionHeader").textContent = "ex commands"
const excmds = Metadata.everything.getFile("src/excmds.ts")
if (!excmds) return
const fns = excmds.getFunctions()
// Add all excmds that start with exstr and that tridactyl has metadata about to completions
this.options = this.scoreOptions(
Object.entries(excmdsFunctions)
.filter(([name]) => name.startsWith(exstr))
.map(
([name, fn]) => new ExcmdCompletionOption(name, getDoc(fn)),
),
fns
.filter(([name, fn]) => !fn.hidden && name.startsWith(exstr))
.map(([name, fn]) => new ExcmdCompletionOption(name, fn.doc)),
)
// Also narrow down aliases map to possible completions
@ -81,13 +80,13 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
for (const alias of Object.keys(exaliases)) {
const cmd = aliases.expandExstr(alias, exaliases)
const fn = excmdsFunctions[cmd]
const fn = excmds.getFunction(cmd)
if (fn) {
this.options.push(
new ExcmdCompletionOption(
alias,
`Alias for \`${cmd}\`. ${getDoc(fn)}`,
`Alias for \`${cmd}\`. ${fn.doc}`,
),
)
} else {
@ -101,35 +100,15 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
// Add partial matched funcs like: 'conf' ~= 'viewconfig'
const seen = new Set(this.options.map(o => o.value))
const partial_options = this.scoreOptions(
Object.entries(excmdsFunctions)
.filter(([name]) => name.includes(exstr) && !seen.has(name))
.map(
([name, fn]) => new ExcmdCompletionOption(name, getDoc(fn)),
),
fns
.filter(
([name, fn]) =>
!fn.hidden && name.includes(exstr) && !seen.has(name),
)
.map(([name, fn]) => new ExcmdCompletionOption(name, fn.doc)),
)
this.options = this.options.concat(partial_options)
const [command] = exstr.trim().split(/\s+/)
if (
this.options.length === 0 &&
command &&
!excmdsFunctions[command] &&
exaliasesConfig[command] === undefined
) {
const query = exstr.toLowerCase()
this.node.querySelector(".sectionHeader").textContent = "ex commands (no matches, falling back to doc search)"
this.options = this.scoreOptions(
Object.entries(excmdsFunctions)
.filter(([, fn]) =>
getDoc(fn).toLowerCase().includes(query),
)
.map(
([name, fn]) =>
new ExcmdCompletionOption(name, getDoc(fn)),
),
)
}
this.options.forEach(o => (o.state = "normal"))
return this.updateChain()
}

View file

@ -7,7 +7,6 @@ class ExtensionsCompletionOption extends Completions.CompletionOptionHTML
constructor(public name: string, public optionsUrl: string) {
super()
this.value = name
this.fuseKeys.push(this.name)
this.html = html`<tr class="option">
@ -61,6 +60,12 @@ export class ExtensionsCompletionSource extends Completions.CompletionSourceFuse
return this.updateDisplay()
}
select(option: ExtensionsCompletionOption) {
this.completion = "extoptions " + option.name
option.state = "focused"
this.lastFocused = option
}
private scoreOptions(options: ExtensionsCompletionOption[]) {
return options.sort((o1, o2) => o1.name.localeCompare(o2.name))
}

View file

@ -39,10 +39,7 @@ export class FileSystemCompletionSource extends Completions.CompletionSourceFuse
}
let [cmd, path] = this.splitOnPrefix(exstr)
if (
cmd === undefined ||
(cmd === "source" && /^--url(?:\s|$)/.test(path))
) {
if (cmd === undefined) {
this.state = "hidden"
return
}

View file

@ -1,171 +1,116 @@
import * as Completions from "../completions"
import { activeTabId } from "@src/lib/webext"
import * as Messaging from "@src/lib/messaging"
import { ownTabId } from "@src/lib/webext"
import * as Completions from "../completions"
import * as config from "@src/lib/config"
class FindCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys
constructor(index: number, match, args: string) {
public fuseKeys = []
constructor(m, reverse = false) {
super()
this.value = [`--jump-to ${index}`, args].filter(Boolean).join(" ")
this.fuseKeys = [match.text, match.precontext, match.postcontext, match.breadcrumbs]
this.value =
(reverse ? "-? " : "") + ("-: " + m.index) + " " + m.rangeData.text
this.fuseKeys.push(m.rangeData.text)
// Create HTMLElement
this.html = html`<tr class="FindCompletionOption option">
<td class="breadcrumbs">${match.breadcrumbs}</td>
<td class="content">
${match.precontext}<span class="match">${match.text}</span
>${match.postcontext}
${m.precontext}<span class="match">${m.rangeData.text}</span
>${m.postcontext}
</td>
<td class="position">${match.position}</td>
</tr>`
}
}
export class FindCompletionSource extends Completions.CompletionSourceFuse {
public options: FindCompletionOption[] = []
private session = Math.random()
private tabId = ownTabId()
private active = false
private request = 0
private findArgs = ""
private pending: Promise<void> = Promise.resolve()
private selection: Promise<void> = Promise.resolve()
public options: FindCompletionOption[]
public prevCompletion = null
public completionCount = 0
constructor(_parent?) {
super(
["find"],
"FindCompletionSource",
html`<table><tr>
<td class="breadcrumbs">Breadcrumbs</td>
<td class="content">Context</td>
<td class="position">Position</td>
</tr></table>`,
)
constructor(private _parent) {
super(["find "], "FindCompletionSource", "Matches")
this._parent.appendChild(this.node)
}
filter(exstr: string) {
if (exstr === this.lastExstr && this.completion) return Promise.resolve()
this.lastExstr = exstr
const [, argstr] = this.splitOnPrefix(exstr)
if (argstr === undefined) return this.cancel()
const request = ++this.request
this.findArgs = argstr.trim()
this.active = true
this.options = []
this.optionContainer.replaceChildren()
this.state = "hidden"
const optionArgs = this.findArgs.split(/(?:^|\s)--(?:\s|$)/, 1)[0]
if (/(^|\s)(--jump-to|-:)=?$/.test(optionArgs)) return this.cancel()
const hasJump = /(^|\s)(--jump-to(?:=|\s)|-:(?:\s|$))/.test(optionArgs)
const regex = /(^|\s)(-r|--regex)(?=\s|$)/.test(optionArgs)
const delay = regex
? new Promise<void>(resolve => window.setTimeout(resolve, 250))
: Promise.resolve()
this.pending = delay
.then(() => {
if (request !== this.request) return
return this.send(
{ session: this.session, completions: !hasJump },
...this.findArgs.split(/\s+/),
)
})
.then(matches => {
if (request !== this.request) return
this.options = hasJump
? []
: (matches || []).map(
match =>
new FindCompletionOption(
match.index,
match,
this.findArgs,
),
)
if (this.options.length) this.updateChain(exstr, this.options)
else this.state = "hidden"
this.resize()
})
.catch(() => {
if (request !== this.request) return
this.options = []
this.state = "hidden"
this.resize()
return this.send({ session: this.session, cancel: true })
})
return Promise.resolve()
}
setStateFromScore() {
this.options.forEach(option => (option.state = "normal"))
this.deselect()
}
scoredOptions() {
return []
}
updateDisplay() {
this.optionContainer.replaceChildren(
...this.options
.filter(option => option.state !== "hidden")
.map(option => option.html),
)
this.next(0)
}
select(option: FindCompletionOption) {
super.select(option)
this.selection = this.preview(option.value, true)
}
async next(inc = 1) {
if (!this.active) return false
const pending = this.pending
if (inc !== 0) await this.pending
if (!this.active || pending !== this.pending) return false
const moved = await super.next(inc)
if (inc !== 0 && moved) {
if (!this.completion) this.selection = this.preview(this.findArgs)
await this.selection
async onInput(exstr) {
const id = this.completionCount++
// If there's already a promise being executed, wait for it to finish
await this.prevCompletion
// Since we might have awaited for this.prevCompletion, we don't have a guarantee we're the last completion the user asked for anymore
if (id === this.completionCount - 1) {
// If we are the last completion
this.prevCompletion = this.updateOptions(exstr)
await this.prevCompletion
}
return moved
}
destroy() {
return this.cancel()
// Overriding this function is important, the default one has a tendency to hide options when you don't expect it
setStateFromScore() {
this.options.forEach(o => (o.state = "normal"))
}
private cancel() {
if (!this.active) return Promise.resolve()
++this.request
this.active = false
this.options = []
this.state = "hidden"
return this.send({ session: this.session, cancel: true }).catch(
() => undefined,
private async updateOptions(exstr?: string) {
if (!exstr) return
// Flag parsing because -? should reverse completions
const tokens = exstr.split(" ")
const flagpos = tokens.indexOf("-?")
const reverse = flagpos >= 0
if (reverse) {
tokens.splice(flagpos, 1)
}
const query = tokens.slice(1).join(" ")
const minincsearchlen = await config.getAsync("minincsearchlen")
// No point if continuing if the user hasn't started searching yet
if (query.length < minincsearchlen) return
let findresults = await config.getAsync("findresults")
const incsearch = (await config.getAsync("incsearch")) === "true"
if (findresults === 0 && !incsearch) return
let incsearchonly = false
if (findresults === 0) {
findresults = 1
incsearchonly = true
}
// Note: the use of activeTabId here might break completions if the user starts searching for a pattern in a really big page and then switches to another tab.
// Getting the tabId should probably be done in the constructor but you can't have async constructors.
const tabId = await activeTabId()
const findings = await Messaging.messageTab(
tabId,
"finding_content",
"find",
[query, findresults, reverse],
)
}
private preview(args: string, selected = false) {
return this.send(
{ session: this.session, completions: false, selected },
...args.split(/\s+/),
)
.then(() => undefined)
.catch(() => undefined)
}
// If the search was successful
if (findings.length > 0) {
// Get match context
const len = await config.getAsync("findcontextlen")
const matches = await Messaging.messageTab(
tabId,
"finding_content",
"getMatches",
[findings, len],
)
private send(preview, ...args: string[]) {
return this.tabId.then(tabId =>
Messaging.messageTab(tabId, "excmd_content", "find", [
preview,
...args,
]),
)
}
if (incsearch)
Messaging.messageTab(tabId, "finding_content", "jumpToMatch", [
query,
false,
0,
])
private resize() {
window.dispatchEvent(new Event("tridactyl-refresh-completions"))
if (!incsearchonly) {
this.options = matches.map(
m => new FindCompletionOption(m, reverse),
)
this.updateChain(exstr, this.options)
}
}
}
}

View file

@ -1,31 +0,0 @@
import * as Completions from "@src/completions"
import { ExcmdCompletionOption } from "@src/completions/Excmd"
import glossary from "@src/.glossary.generated.json"
export function glossaryOptions(createOption, query: string, prefix: boolean) {
const needle = query.toLowerCase()
return glossary
.filter(entry =>
prefix
? entry.word.toLowerCase().startsWith(needle)
: (entry.word + entry.definition)
.toLowerCase()
.includes(needle),
)
.map(entry =>
createOption(entry.word, `Glossary. ${entry.definition}`, "-g"),
)
}
export class GlossaryCompletionSource extends Completions.CompletionSourceFuse {
public options: ExcmdCompletionOption[]
constructor(parent) {
super(["define"], "GlossaryCompletionSource", "Glossary")
this.options = glossary.map(
entry => new ExcmdCompletionOption(entry.word, entry.definition),
)
this.sortScoredOptions = true
parent.appendChild(this.node)
}
}

View file

@ -12,7 +12,7 @@ class GotoCompletionOption
this.fuseKeys.push(title)
this.html = html`<tr class="GotoCompletionOption option">
<td class="title">${Completions.treePrefix(level)}${title}</td>
<td class="title" style="padding-left: ${level * 4}ch">${title}</td>
</tr>`
}
}

View file

@ -1,23 +1,13 @@
import * as Completions from "@src/completions"
import { AproposCompletionSource } from "@src/completions/Apropos"
import {
excmdsFunctions,
defaultConfigMembers,
getDoc,
memberDoc,
} from "@src/.metadata.generated"
import * as Metadata from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
import { glossaryOptions } from "@src/completions/Glossary"
class HelpCompletionOption extends Completions.CompletionOptionHTML implements Completions.CompletionOptionFuse {
class HelpCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(
public name: string,
doc: string,
flag: string,
) {
constructor(public name: string, doc: string, flag: string) {
super()
this.value = `${flag} ${name}`
this.html = html`<tr class="HelpCompletionOption option">
@ -27,15 +17,13 @@ class HelpCompletionOption extends Completions.CompletionOptionHTML implements C
}
}
export class HelpCompletionSource extends AproposCompletionSource {
export class HelpCompletionSource extends Completions.CompletionSourceFuse {
public options: HelpCompletionOption[]
constructor(_parent) {
super(_parent, ["help"], "HelpCompletionSource", "Help")
}
constructor(private _parent) {
super(["help"], "HelpCompletionSource", "Help")
protected createOption(name: string, doc: string, flag: string) {
return new HelpCompletionOption(name, doc, flag)
this._parent.appendChild(this.node)
}
public async filter(exstr: string) {
@ -53,15 +41,21 @@ export class HelpCompletionSource extends AproposCompletionSource {
this.state = "hidden"
return
}
this.node.querySelector(".sectionHeader").textContent = "Help (prefix matches)"
const file = Metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const excmds = Metadata.everything.getFile("src/excmds.ts")
const fns = excmds.getFunctions()
const settings = config.get()
const exaliases = settings.exaliases
const bindings = settings.nmaps
if (exaliases === undefined || bindings === undefined) {
if (
fns === undefined ||
exaliases === undefined ||
bindings === undefined
) {
return
}
const fns = Object.entries(excmdsFunctions)
const flags = {
"-a": (options, query) =>
@ -70,7 +64,9 @@ export class HelpCompletionSource extends AproposCompletionSource {
.filter(alias => alias.startsWith(query))
.map(alias => {
const cmd = aliases.expandExstr(alias)
const doc = getDoc(excmdsFunctions[cmd])
const doc =
(excmds.getFunction(cmd) || ({} as any)).doc ||
""
return new HelpCompletionOption(
alias,
`Alias for \`${cmd}\`. ${doc}`,
@ -94,12 +90,15 @@ export class HelpCompletionSource extends AproposCompletionSource {
"-e": (options, query) =>
options.concat(
fns
.filter(([name]) => name.startsWith(query))
.filter(
([name, fn]) =>
!fn.hidden && name.startsWith(query),
)
.map(
([name, fn]) =>
new HelpCompletionOption(
name,
`Excmd. ${getDoc(fn)}`,
`Excmd. ${fn.doc}`,
"-e",
),
),
@ -109,7 +108,11 @@ export class HelpCompletionSource extends AproposCompletionSource {
Object.keys(settings)
.filter(x => x.startsWith(query))
.map(setting => {
const doc = memberDoc(defaultConfigMembers[setting])
const member = default_config.getMember(setting)
let doc = ""
if (member !== undefined) {
doc = member.doc
}
return new HelpCompletionOption(
setting,
`Setting. ${doc}`,
@ -117,10 +120,6 @@ export class HelpCompletionSource extends AproposCompletionSource {
)
}),
),
"-g": (options, query) =>
options.concat(
glossaryOptions(this.createOption.bind(this), query, true),
),
}
const args = query.split(" ")
@ -134,15 +133,18 @@ export class HelpCompletionSource extends AproposCompletionSource {
)
}
if (opts.length === 0) {
this.node.querySelector(".sectionHeader").textContent = "Help (prefix match failed, showing :apropos matches)"
return super.filter(exstr)
}
this.options = opts
this.options.sort((compopt1, compopt2) =>
compopt1.name.localeCompare(compopt2.name),
)
return this.updateChain()
}
updateChain() {
// Options are pre-trimmed to the right length.
this.options.forEach(option => (option.state = "normal"))
// Call concrete class
return this.updateDisplay()
}
}

View file

@ -18,8 +18,7 @@ class HistoryCompletionOption
let preplain = page.bmark ? "B" : ""
preplain += page.search ? "S" : ""
let pre = preplain
// Tab settings apply here to grandfather in people who set it before when there was a bug
if ((config.get("completions", "Tab", "statusstylepretty") === "true") || (config.get("completions", "History", "statusstylepretty") === "true")) {
if (config.get("completions", "Tab", "statusstylepretty") === "true") {
pre = page.bmark ? "\u2B50" : ""
pre += page.search ? "\u{1F50D}" : ""
}
@ -34,7 +33,7 @@ class HistoryCompletionOption
<td class="title">${page.title}</td>
<td class="content">
${page.search ? "Search " : ""}
<a class="url" target="_blank" href=${page.url}>${Completions.decodeUrlForDisplay(page.url)}</a>
<a class="url" target="_blank" href=${page.url}>${page.url}</a>
</td>
</tr>`
}
@ -72,7 +71,6 @@ export class HistoryCompletionSource extends Completions.CompletionSourceFuse {
}
const headerPostfix = []
prefix = this.canonicalisePrefix(prefix)
// Ignoring command-specific arguments
// It's terrible but it's ok because it's just a stopgap until an actual commandline-parsing API is implemented
@ -140,13 +138,6 @@ export class HistoryCompletionSource extends Completions.CompletionSourceFuse {
break
}
}
if (
this.completion === undefined &&
this.options.length > 0 &&
config.get("completions", "History", "autoselect") === "true"
) {
this.select(this.options[0])
}
return this.updateDisplay()
}
@ -156,10 +147,7 @@ export class HistoryCompletionSource extends Completions.CompletionSourceFuse {
updateChain() {}
private async scoreOptions(query: string, n: number) {
if (
(!query && config.get("historysort") === "frequency") ||
config.get("historyresults") === 0
) {
if (!query || config.get("historyresults") === 0) {
return (await providers.getTopSites()).slice(0, n)
} else {
return (await providers.getCombinedHistoryBmarks(query)).slice(0, n)

Some files were not shown because too many files have changed in this diff Show more