diff --git a/.github/workflows/docker-io.yml b/.github/workflows/docker-io.yml index beff4589..8cbeb042 100644 --- a/.github/workflows/docker-io.yml +++ b/.github/workflows/docker-io.yml @@ -1,4 +1,6 @@ name: Docker IO +permissions: + contents: write on: - push jobs: @@ -49,7 +51,7 @@ jobs: with: context: . file: docker/Dockerfile.io - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: | coderaiser/cloudcmd:io diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1a85b29e..5c81d368 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,6 @@ name: Docker CI +permissions: + contents: write on: push: tags: diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b9ed9cb5..429e5568 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,6 +1,8 @@ name: Node CI on: - push +permissions: + contents: write jobs: build: runs-on: ubuntu-latest @@ -22,24 +24,17 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Install Redrun - run: bun i redrun -g --no-save + run: bun i redrun madrun -g --no-save - name: Install run: bun i --no-save + - name: Init Madrun + run: madrun --init - name: Lint run: redrun fix:lint - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Typos Install - run: cargo install typos-cli || echo 'already installed' - name: Typos - run: typos --write-changes + uses: coderaiser/typos.ai@v1.1.8 + with: + key: ${{ secrets.TYPOS_AI_KEY }} - name: Commit fixes uses: EndBug/add-and-commit@v10 continue-on-error: true diff --git a/.madrun.js b/.madrun.js index 9bd30e3d..efa8ce98 100644 --- a/.madrun.js +++ b/.madrun.js @@ -21,14 +21,15 @@ export default { 'start:dev', ]), 'lint:all': () => run('lint:progress'), - 'lint': () => 'redlint fix; putout . --rulesdir rules', + 'lint': () => 'redlint fix; putout .', 'lint:progress': () => run('lint', '-f progress'), 'watch:lint': () => 'nodemon -w client -w server -w test -w common -w .webpack -x "putout -s"', 'fresh:lint': () => run('lint', '--fresh'), 'lint:fresh': () => run('lint', '--fresh'), - 'fix:lint': async () => `putout --rulesdir rules --fix . && redlint fix`, + 'fix:lint': async () => `putout --fix . && redlint fix`, 'lint:stream': () => run('lint', '-f stream'), - 'test': () => [testEnv, `tape 'test/**/*.js' '{bin,client,static,common,server}/**/*.spec.js' -f fail`], + 'test': () => [testEnv, `tape '{test}/**/*.js' '{bin,client,static,common,server}/**/*.spec.js' -f fail`], + 'test:e2e': () => `tape 'test-e2e/**/*.js'`, 'test:client': () => `tape 'test/client/**/*.js'`, 'test:server': () => `tape 'test/**/*.js' 'server/**/*.spec.js' 'common/**/*.spec.js'`, 'wisdom': async () => await run(['lint:all', 'build', 'test'], null, { @@ -38,7 +39,7 @@ export default { 'coverage': async () => [testEnv, `c8 ${await cutEnv('test')}`], 'coverage:report': () => 'c8 report --reporter=lcov', 'report': () => 'c8 report --reporter=lcov', - '6to5': () => [buildEnv, 'webpack --progress'], + '6to5': () => [buildEnv, 'rspack build --config rspack.config.js'], '6to5:client': () => run('6to5', '--mode production'), '6to5:client:dev': async () => await run('6to5', '--mode development', { NODE_ENV: 'development', diff --git a/.npmignore b/.npmignore index 04da926f..b83b5923 100644 --- a/.npmignore +++ b/.npmignore @@ -20,7 +20,7 @@ img/logo/cloudcmd-hq.png webpack.config.js docker -test +test* fixture fixture-* coverage diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 1efe0ac6..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v20.15.1 diff --git a/.webpack/css.js b/.rspack/css.js similarity index 53% rename from .webpack/css.js rename to .rspack/css.js index 311290cc..10d28250 100644 --- a/.webpack/css.js +++ b/.rspack/css.js @@ -1,19 +1,18 @@ import {env} from 'node:process'; -import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; -import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import {rspack} from '@rspack/core'; +const {CssExtractRspackPlugin} = rspack; const isDev = env.NODE_ENV === 'development'; -const clean = (a) => a.filter(Boolean); -const plugins = clean([ - new MiniCssExtractPlugin({ +const plugins = [ + new CssExtractRspackPlugin({ filename: '[name].css', }), -]); +]; const rules = [{ test: /\.css$/i, - use: [MiniCssExtractPlugin.loader, { + use: [CssExtractRspackPlugin.loader, { loader: 'css-loader', options: { url: true, @@ -33,13 +32,7 @@ export default { optimization: { minimize: !isDev, minimizer: [ - new CssMinimizerPlugin({ - minimizerOptions: { - preset: ['default', { - svgo: false, - }], - }, - }), + new rspack.LightningCssMinimizerRspackPlugin(), ], }, }; diff --git a/.webpack/html.js b/.rspack/html.js similarity index 100% rename from .webpack/html.js rename to .rspack/html.js diff --git a/.webpack/js.js b/.rspack/js.js similarity index 81% rename from .webpack/js.js rename to .rspack/js.js index fdc88556..251d85f6 100644 --- a/.webpack/js.js +++ b/.rspack/js.js @@ -1,12 +1,15 @@ import {resolve, sep} from 'node:path'; +import {fileURLToPath} from 'node:url'; import {env} from 'node:process'; -import webpack from 'webpack'; -import WebpackBar from 'webpackbar'; +import {rspack} from '@rspack/core'; + +const resolveModule = (a) => fileURLToPath(import.meta.resolve(a)); const { EnvironmentPlugin, NormalModuleReplacementPlugin, -} = webpack; + ProvidePlugin, +} = rspack; const modules = './modules'; const dirModules = './client/modules'; @@ -22,27 +25,31 @@ const dist = resolve(rootDir, 'dist'); const distDev = resolve(rootDir, 'dist-dev'); const devtool = isDev ? 'eval' : 'source-map'; -const notEmpty = (a) => a; -const clean = (array) => array.filter(notEmpty); - const noParse = (a) => a.endsWith('.spec.js'); -const options = { - babelrc: true, -}; -const rules = clean([ - !isDev && { - test: /\.[mc]?js$/, - exclude: /node_modules/, - loader: 'babel-loader', +// codegen.macro is a babel-macro (build-time codegen), not supported by +// Rspack's native SWC transform, so client/sw/sw.js (the only file that +// uses it) keeps going through babel-loader. Everything else uses +// Rspack's built-in SWC loader, which is the main source of the speedup. +const rules = [{ + test: /sw\/sw\.js$/, + exclude: /node_modules/, + loader: 'babel-loader', +}, { + test: /\.[mc]?js$/, + exclude: [/node_modules/, /sw\/sw\.js$/], + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'ecmascript', + }, + }, + env: { + targets: 'defaults', + }, }, - isDev && { - test: /\.[mc]?js$/, - exclude: /node_modules/, - loader: 'babel-loader', - options, - }, -]); +}]; const plugins = [ new NormalModuleReplacementPlugin(/^node:/, (resource) => { @@ -52,8 +59,7 @@ const plugins = [ new EnvironmentPlugin({ NODE_ENV, }), - new WebpackBar(), - new webpack.ProvidePlugin({ + new ProvidePlugin({ process: 'process/browser', }), ]; @@ -99,9 +105,9 @@ export default { 'node:path': 'path', }, fallback: { - path: import.meta.resolve('path-browserify'), - process: import.meta.resolve('process/browser'), - util: import.meta.resolve('util'), + path: resolveModule('path-browserify'), + process: resolveModule('process/browser'), + util: resolveModule('util'), }, }, devtool, @@ -156,7 +162,7 @@ export default { }, plugins, performance: { - maxEntrypointSize: 600_000, + maxEntrypointSize: 800_000, maxAssetSize: 600_000, }, }; diff --git a/ChangeLog b/ChangeLog index 1132ea96..c5e0fbca 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,30 @@ +2026.06.15, v19.19.1 + +feature: +- 78b206e0 client: key: Ctrl + L -> Ctrl + Shift + L: Log out (#466) +- b1492885 docker: io: NPM_CONFIG_PACKAGE_LOCK=false +- 76d7bf4d docker: typos, shellcheck: add + +2026.05.26, v19.19.0 + +feature: +- 4ee38db6 client: view: flac: add support + +2026.05.26, v19.18.1 + +feature: +- 40ecef5e cloudcmd: ratelimit: X-Forwarded-For (#437) + +2026.05.26, v19.18.0 + +fix: +- 161bede8 client: menu: @putout/bundle@5.5. +- 447d990f cloudcmd: server: rest: sendError + +feature: +- 5d9628ce cloudcmd: rate limit: add support (#437) +- 2be395e6 cloudcmd: get rid of manifest: 401 basic auth + 2026.05.17, v19.17.0 fix: diff --git a/HELP.md b/HELP.md index 783eb088..2967f0d0 100644 --- a/HELP.md +++ b/HELP.md @@ -1,6 +1,6 @@ -# Cloud Commander v19.17.0 +# Cloud Commander v19.19.1 -### [Main][MainURL] [Blog][BlogURL] [Support][SupportURL] [Demo][DemoURL] +### [Main][MainURL] [Blog][BlogURL] [Demo][DemoURL] [Deploy](#deploy) [MainURL]: https://cloudcmd.io "Main" [BlogURL]: https://blog.cloudcmd.io "Blog" @@ -14,6 +14,9 @@ [TERMUX]: https://termux.com "Termux" [INLY]: https://github.com/coderaiser/node-inly "Extract archive" +[DeployInstaPodsIMG]: https://img.shields.io/badge/deploy%20on-InstaPods-blue +[DeployInstaPodsURL]: https://app.instapods.com/dashboard/pods/create?app=cloudcmd&ref=cloudcmd + **Cloud Commander** is a file manager for the web. It includes a command-line console and a text editor. Cloud Commander helps you manage your server and work with files, directories and programs in a web browser from any computer, mobile or tablet. ![Cloud Commander](https://cloudcmd.io/img/logo/cloudcmd.png "Cloud Commander") @@ -187,12 +190,12 @@ Then, start the server again with `cloudcmd` and reload the page. | `Ctrl + R` | refresh | `Ctrl + D` | clear local storage | `Ctrl + A` | select all files in a panel -| `Ctrl + L` | logout | `Ctrl + M` | [rename selected files](https://github.com/coderaiser/cloudcmd/releases/tag/v12.1.0) in editor | `Ctrl + U` | swap panels | `Ctrl + F3` | sort by name | `Ctrl + F5` | sort by date | `Ctrl + F6` | sort by size +| `Ctrl + Shift + L` | logout | `Ctrl + Command + .` | show/hide dot files | `Up` | move cursor up | `Down` | move cursor down @@ -930,9 +933,9 @@ ln -s ./sites-available/io.cloudcmd.io ./sites-enabled ## Deploy -`Cloud Commander` can be easily deployed to [Heroku](https://heroku.com/deploy?template=https://github.com/coderaiser/cloudcmd "Deploy to Heroku"). +`Cloud Commander` can be easily deployed to [InstaPods][DeployInstaPodsURL]. After deploy you receive email with credentials. -[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png "Deploy to Heroku")](https://heroku.com/deploy?template=https://github.com/coderaiser/cloudcmd) +[![Deploy on InstaPods](https://instapods.com/deploy-button.svg)](https://app.instapods.com/dashboard/pods/create?app=cloudcmd&ref=cloudcmd) ## Docker @@ -1119,6 +1122,10 @@ There are a lot of ways to be involved in `Cloud Commander` development: ## Version history +- *2026.06.15*, **[v19.19.1](//github.com/coderaiser/cloudcmd/releases/tag/v19.19.1)** +- *2026.05.26*, **[v19.19.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.19.0)** +- *2026.05.26*, **[v19.18.1](//github.com/coderaiser/cloudcmd/releases/tag/v19.18.1)** +- *2026.05.26*, **[v19.18.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.18.0)** - *2026.05.17*, **[v19.17.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.17.0)** - *2026.05.03*, **[v19.16.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.16.0)** - *2026.04.28*, **[v19.15.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.15.0)** diff --git a/README.md b/README.md index 806e734d..dfe8d8c6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Cloud Commander v19.17.0 [![Build Status][BuildStatusIMGURL]][BuildStatusURL] [![Codacy][CodacyIMG]][CodacyURL] [![Gitter][GitterIMGURL]][GitterURL] +# Cloud Commander v19.19.1 [![Build Status][BuildStatusIMGURL]][BuildStatusURL] [![Codacy][CodacyIMG]][CodacyURL] [![Gitter][GitterIMGURL]][GitterURL] [![Deploy on InstaPods][DeployInstaPodsIMG]][DeployInstaPodsURL] ### [Main][MainURL] [Blog][BlogURL] [Support][SupportURL] [Demo][DemoURL] @@ -15,6 +15,8 @@ [GitterIMGURL]: https://img.shields.io/gitter/room/coderaiser/cloudcmd.js.svg [DeployURL]: https://heroku.com/deploy?template=https://github.com/coderaiser/cloudcmd "Deploy" [DeployIMG]: https://www.herokucdn.com/deploy/button.png +[DeployInstaPodsIMG]: https://img.shields.io/badge/deploy%20on-InstaPods-blue +[DeployInstaPodsURL]: https://app.instapods.com/dashboard/pods/create?app=cloudcmd&ref=cloudcmd **Cloud Commander** a file manager for the web with console and editor. diff --git a/bin/cloudcmd.js b/bin/cloudcmd.js index bdf88379..6ca6716c 100755 --- a/bin/cloudcmd.js +++ b/bin/cloudcmd.js @@ -115,7 +115,7 @@ const yargsOptions = { 'columns': env.parse('columns') || config('columns') || '', 'theme': env.parse('theme') || config('theme') || '', 'vim': choose(env.bool('vim'), config('vim')), - 'log': config('log'), + 'log': choose(env.bool('log'), config('log')), 'import-url': env.parse('import_url') || config('importUrl'), 'import-listen': choose(env.bool('import_listen'), config('importListen')), @@ -264,7 +264,8 @@ function validateRoot(root, config) { if (root === '/') return; - console.log(`root: ${root}`); + if (config('log')) + console.log(`root: ${root}`); } async function getPassword(password) { diff --git a/client/cloudcmd.js b/client/cloudcmd.js index 688d97b7..f2416fd4 100644 --- a/client/cloudcmd.js +++ b/client/cloudcmd.js @@ -36,7 +36,7 @@ async function init(config) { if (globalThis.CloudCmd.config('menu') === 'aleman') setTimeout(() => { import('https://esm.sh/@putout/processor-html'); - import('https://esm.sh/@putout/bundle'); + import('https://esm.sh/@putout/bundle@5.5'); }, 100); } diff --git a/client/dom/current-file.spec.js b/client/dom/current-file.spec.js index 6bf0509b..85b18a29 100644 --- a/client/dom/current-file.spec.js +++ b/client/dom/current-file.spec.js @@ -19,14 +19,13 @@ test('current-file: setCurrentName: setAttribute', (t) => { currentFile.setCurrentName('hello', current); + globalThis.DOM = DOM; + globalThis.CloudCmd = CloudCmd; + t.calledWith(setAttribute, [ 'data-name', 'js-file-aGVsbG8=', ], 'should call setAttribute'); - - globalThis.DOM = DOM; - globalThis.CloudCmd = CloudCmd; - t.end(); }); @@ -41,14 +40,13 @@ test('current-file: setCurrentName: setAttribute: cyrillic', (t) => { currentFile.setCurrentName('ай', current); + globalThis.DOM = DOM; + globalThis.CloudCmd = CloudCmd; + t.calledWith(setAttribute, [ 'data-name', 'js-file-JUQwJUIwJUQwJUI5', ], 'should call setAttribute'); - - globalThis.DOM = DOM; - globalThis.CloudCmd = CloudCmd; - t.end(); }); @@ -76,11 +74,10 @@ test('current-file: emit', (t) => { currentFile.setCurrentName('hello', current); - t.calledWith(emit, ['current-file', current], 'should call emit'); - globalThis.DOM = DOM; globalThis.CloudCmd = CloudCmd; + t.calledWith(emit, ['current-file', current], 'should call emit'); t.end(); }); @@ -96,14 +93,12 @@ test('current-file: setCurrentName: return', (t) => { globalThis.CloudCmd = getCloudCmd(); const current = create(); - const result = currentFile.setCurrentName('hello', current); - t.equal(result, link, 'should return link'); - globalThis.DOM = DOM; globalThis.CloudCmd = CloudCmd; + t.equal(result, link, 'should return link'); t.end(); }); diff --git a/client/dom/dom-tree.spec.js b/client/dom/dom-tree.spec.js index 13f56f6d..53b376c7 100644 --- a/client/dom/dom-tree.spec.js +++ b/client/dom/dom-tree.spec.js @@ -1,7 +1,16 @@ -import test from 'supertape'; +import {test, stub} from 'supertape'; import {create} from 'auto-globals'; import {tryCatch} from 'try-catch'; -import {isContainClass} from './dom-tree.js'; +import { + isContainClass, + getByTag, + getById, + getByClass, + getByDataName, + getByClassAll, + hide, + show, +} from './dom-tree.js'; test('dom: isContainClass: no element', (t) => { const [e] = tryCatch(isContainClass); @@ -38,3 +47,106 @@ test('dom: isContainClass: contains: array', (t) => { t.calledWith(contains, [className], 'should call contains'); t.end(); }); + +test('dom: getByTag', (t) => { + const getElementsByTagName = stub(); + const element = { + getElementsByTagName, + }; + + getByTag('div', element); + + t.calledWith(getElementsByTagName, ['div'], 'should call getElementsByTagName'); + t.end(); +}); + +test('dom: getById', (t) => { + const querySelector = stub(); + const element = { + querySelector, + }; + + getById('my-id', element); + + t.calledWith(querySelector, ['#my-id'], 'should call querySelector with id selector'); + t.end(); +}); + +test('dom: getByClassAll', (t) => { + const getElementsByClassName = stub(); + const element = { + getElementsByClassName, + }; + + getByClassAll('my-class', element); + + t.calledWith(getElementsByClassName, ['my-class'], 'should call getElementsByClassName'); + t.end(); +}); + +test('dom: getByClass: calls getByClassAll', (t) => { + const element = { + getElementsByClassName: stub().returns(['first']), + }; + + const result = getByClass('my-class', element); + + t.equal(result, 'first', 'should return first element from class list'); + t.end(); +}); + +test('dom: getByDataName', (t) => { + const querySelector = stub(); + const element = { + querySelector, + }; + + getByDataName('hello', element); + + t.calledWith(querySelector, ['[data-name="hello"]'], 'should call querySelector with data-name selector'); + t.end(); +}); + +test('dom: hide', (t) => { + const add = stub(); + const element = { + classList: { + add, + }, + }; + + hide(element); + + t.calledWith(add, ['hidden'], 'should add hidden class'); + t.end(); +}); + +test('dom: show', (t) => { + const remove = stub(); + const element = { + classList: { + remove, + }, + }; + + show(element); + + t.calledWith(remove, ['hidden'], 'should remove hidden class'); + t.end(); +}); + +test('dom: getByClassAll: without element uses document', (t) => { + const getElementsByClassName = stub(); + const prevDocument = globalThis.document; + + globalThis.document = { + getElementsByClassName, + }; + + getByClassAll('my-class'); + + globalThis.document = prevDocument; + + t.calledWith(getElementsByClassName, ['my-class'], 'should fallback to document when no element'); + t.end(); +}); diff --git a/client/dom/index.js b/client/dom/index.js index f6ce6bc7..a82b6f72 100644 --- a/client/dom/index.js +++ b/client/dom/index.js @@ -442,11 +442,7 @@ export function getFilenames(files) { if (name === '..') allFiles.shift(); - const names = allFiles.map((current) => { - return DOM.getCurrentName(current); - }); - - return names; + return allFiles.map(DOM.getCurrentName); } /** diff --git a/client/dom/load-remote.js b/client/dom/load-remote.js index 43af0cf5..5be8e327 100644 --- a/client/dom/load-remote.js +++ b/client/dom/load-remote.js @@ -33,13 +33,19 @@ export const loadRemote = callbackify(async (name, options) => { local = [module.local]; } - const localURL = local.map((url) => prefix + url); + const localURL = []; - const remoteURL = remoteTmpls.map((tmpl) => { - return rendy(tmpl, { + for (const url of local) { + localURL.push(prefix + url); + } + + const remoteURL = []; + + for (const tmpl of remoteTmpls) { + remoteURL.push(rendy(tmpl, { version, - }); - }); + })); + } if (online) { const [e] = await tryToCatch(load.parallel, remoteURL); diff --git a/client/key/binder.spec.js b/client/key/binder.spec.js new file mode 100644 index 00000000..f9d54fdd --- /dev/null +++ b/client/key/binder.spec.js @@ -0,0 +1,28 @@ +import {test} from 'supertape'; +import {createBinder} from './binder.js'; + +test('client: key: binder: isBind: default', (t) => { + const binder = createBinder(); + + t.notOk(binder.isBind(), 'should not be bind by default'); + t.end(); +}); + +test('client: key: binder: setBind', (t) => { + const binder = createBinder(); + + binder.setBind(); + + t.ok(binder.isBind(), 'should be bind'); + t.end(); +}); + +test('client: key: binder: unsetBind', (t) => { + const binder = createBinder(); + + binder.setBind(); + binder.unsetBind(); + + t.notOk(binder.isBind(), 'should not be bind'); + t.end(); +}); diff --git a/client/key/index.js b/client/key/index.js index 6f55746c..283a7f11 100644 --- a/client/key/index.js +++ b/client/key/index.js @@ -443,7 +443,7 @@ async function _switchKey(event) { break; case KEY.L: - if (ctrlMeta) { + if (ctrlMeta && shift) { CloudCmd.logOut(); event.preventDefault(); } diff --git a/client/key/vim/find.spec.js b/client/key/vim/find.spec.js index 59c9a249..191a3652 100644 --- a/client/key/vim/find.spec.js +++ b/client/key/vim/find.spec.js @@ -1,6 +1,12 @@ import test from 'supertape'; import {getDOM} from './globals.fixture.js'; -import {_next, _previous} from './find.js'; +import { + _next, + _previous, + find, + findNext, + findPrevious, +} from './find.js'; globalThis.DOM = getDOM(); @@ -11,9 +17,39 @@ test('cloudcmd: client: vim: _next', (t) => { t.end(); }); +test('cloudcmd: client: vim: _next: increment', (t) => { + const result = _next(0, 2); + + t.equal(result, 1, 'should return 1'); + t.end(); +}); + test('cloudcmd: client: vim: _previous', (t) => { const result = _previous(0, 2); t.equal(result, 1, 'should return 1'); t.end(); }); + +test('cloudcmd: client: vim: _previous: decrement', (t) => { + const result = _previous(1, 2); + + t.equal(result, 0, 'should return 0'); + t.end(); +}); + +test('cloudcmd: client: vim: findNext: after find', (t) => { + find('a', ['alpha', 'beta', 'apple']); + const result = findNext(); + + t.equal(result, 'beta', 'should return next found name'); + t.end(); +}); + +test('cloudcmd: client: vim: findPrevious: after find', (t) => { + find('a', ['alpha', 'beta', 'apple']); + const result = findPrevious(); + + t.equal(result, 'apple', 'should return previous found name'); + t.end(); +}); diff --git a/client/key/vim/index.spec.js b/client/key/vim/index.spec.js index e1ca4eee..231c6577 100644 --- a/client/key/vim/index.spec.js +++ b/client/key/vim/index.spec.js @@ -1,6 +1,7 @@ import {test, stub} from 'supertape'; import {getDOM, getCloudCmd} from './globals.fixture.js'; import vim, {selectFile as vimSelectFile} from './index.js'; +import * as finder from './find.js'; globalThis.DOM = getDOM(); globalThis.CloudCmd = getCloudCmd(); @@ -675,3 +676,81 @@ test('cloudcmd: client: vim: rename', async (t) => { t.calledWithNoArgs(renameCurrent); t.end(); }); + +test('cloudcmd: client: key: cc: operationCopy', (t) => { + const show = stub(); + const preventDefault = stub(); + + const Operation = { + show, + }; + + const event = { + preventDefault, + }; + + vim('c', event, { + Operation, + }); + + vim('c', event, { + Operation, + }); + + t.calledWith(show, ['copy'], 'should show copy operation'); + t.end(); +}); + +test('cloudcmd: client: key: mm: operationMove', (t) => { + const show = stub(); + const preventDefault = stub(); + + const Operation = { + show, + }; + + const event = { + preventDefault, + }; + + vim('m', event, { + Operation, + }); + + vim('m', event, { + Operation, + }); + + t.calledWith(show, ['move'], 'should show move operation'); + t.end(); +}); + +test('cloudcmd: client: key: n: findNext: real', (t) => { + const setCurrentByName = stub(); + + finder.find('a', ['alpha', 'beta', 'apple']); + + const event = {}; + + vim('n', event, { + setCurrentByName, + }); + + t.calledWith(setCurrentByName, ['beta'], 'should set current by next found name'); + t.end(); +}); + +test('cloudcmd: client: key: N: findPrevious: real', (t) => { + const setCurrentByName = stub(); + + finder.find('a', ['alpha', 'beta', 'apple']); + + const event = {}; + + vim('N', event, { + setCurrentByName, + }); + + t.calledWith(setCurrentByName, ['apple'], 'should set current by previous found name'); + t.end(); +}); diff --git a/client/modules/config/input.spec.js b/client/modules/config/input.spec.js index 537c1632..9d0866cd 100644 --- a/client/modules/config/input.spec.js +++ b/client/modules/config/input.spec.js @@ -1,5 +1,10 @@ -import {test} from 'supertape'; -import {convert} from './input.js'; +import {test, stub} from 'supertape'; +import { + convert, + getName, + getValue, + setValue, +} from './input.js'; test('cloudcmd: client: config: input: convert', (t) => { const result = convert({ @@ -13,3 +18,123 @@ test('cloudcmd: client: config: input: convert', (t) => { t.deepEqual(result, expected); t.end(); }); + +test('cloudcmd: client: config: input: convert: bool', (t) => { + const result = convert({ + auth: true, + }); + + const expected = { + auth: ' checked', + }; + + t.deepEqual(result, expected); + t.end(); +}); + +test('cloudcmd: client: config: input: convert: bool false', (t) => { + const result = convert({ + auth: false, + }); + + const expected = { + auth: '', + }; + + t.deepEqual(result, expected); + t.end(); +}); + +test('cloudcmd: client: config: input: getName', (t) => { + const getAttribute = stub().returns('js-hello'); + const element = { + getAttribute, + }; + + const result = getName(element); + + t.equal(result, 'hello', 'should strip js- prefix'); + t.end(); +}); + +test('cloudcmd: client: config: input: getValue: checkbox', (t) => { + const querySelector = stub().returns({ + type: 'checkbox', + checked: true, + }); + + const element = { + querySelector, + }; + + const result = getValue('auth', element); + + t.ok(result, 'should return checked value'); + t.end(); +}); + +test('cloudcmd: client: config: input: getValue: number', (t) => { + const querySelector = stub().returns({ + type: 'number', + value: '42', + }); + + const element = { + querySelector, + }; + + const result = getValue('port', element); + + t.equal(result, 42, 'should return number'); + t.end(); +}); + +test('cloudcmd: client: config: input: getValue: default', (t) => { + const querySelector = stub().returns({ + type: 'text', + value: 'hello', + }); + + const element = { + querySelector, + }; + + const result = getValue('name', element); + + t.equal(result, 'hello', 'should return value as is'); + t.end(); +}); + +test('cloudcmd: client: config: input: setValue: checkbox', (t) => { + const el = { + type: 'checkbox', + checked: false, + }; + + const querySelector = stub().returns(el); + const element = { + querySelector, + }; + + setValue('auth', true, element); + + t.ok(el.checked, 'should set checked'); + t.end(); +}); + +test('cloudcmd: client: config: input: setValue: default', (t) => { + const el = { + type: 'text', + value: 'old', + }; + + const querySelector = stub().returns(el); + const element = { + querySelector, + }; + + setValue('name', 'new', element); + + t.equal(el.value, 'new', 'should set value'); + t.end(); +}); diff --git a/client/modules/menu/index.js b/client/modules/menu/index.js index 249e755c..7235591e 100644 --- a/client/modules/menu/index.js +++ b/client/modules/menu/index.js @@ -1,4 +1,3 @@ -/* global CloudCmd, DOM */ import exec from 'execon'; import wrap from 'wraptile'; import createElement from '@cloudcmd/create-element'; @@ -6,6 +5,7 @@ import {getIdBySrc} from '#dom/load'; import * as RESTful from '#dom/rest'; import {FS} from '#common/cloudfunc'; +const {CloudCmd, DOM} = globalThis; const {config, Key} = CloudCmd; const { diff --git a/client/modules/view/index.spec.js b/client/modules/view/index.spec.js index 8d184548..abfbe3f4 100644 --- a/client/modules/view/index.spec.js +++ b/client/modules/view/index.spec.js @@ -31,8 +31,10 @@ test('cloudcmd: client: view: initConfig', (t) => { test('cloudcmd: client: view: initConfig: no options', (t) => { const config = _initConfig(); + const result = typeof config; + const expected = 'object'; - t.equal(typeof config, 'object'); + t.equal(result, expected); t.end(); }); diff --git a/client/modules/view/types.js b/client/modules/view/types.js index 2e001fe1..320a4bc8 100644 --- a/client/modules/view/types.js +++ b/client/modules/view/types.js @@ -1,7 +1,7 @@ import {extname} from 'node:path'; import currify from 'currify'; -export const isAudio = (name) => /\.(mp3|ogg|m4a)$/i.test(name); +export const isAudio = (name) => /\.(mp3|ogg|m4a|flac)$/i.test(name); const testRegExp = currify((name, reg) => reg.test(name)); const getRegExp = (ext) => RegExp(`\\.${ext}$`, 'i'); diff --git a/client/modules/view/types.spec.js b/client/modules/view/types.spec.js index 7ed159f4..f0542c18 100644 --- a/client/modules/view/types.spec.js +++ b/client/modules/view/types.spec.js @@ -8,6 +8,13 @@ test('cloudcmd: client: view: types: isAudio', (t) => { t.end(); }); +test('cloudcmd: client: view: types: isAudio: flac', (t) => { + const result = isAudio('hello.flac'); + + t.ok(result); + t.end(); +}); + test('cloudcmd: client: view: types: isAudio: no', (t) => { const result = isAudio('hello'); diff --git a/client/sw/register.spec.js b/client/sw/register.spec.js index 47420ba7..52699f0b 100644 --- a/client/sw/register.spec.js +++ b/client/sw/register.spec.js @@ -65,7 +65,7 @@ test('sw: register: registerSW: http', async (t, {location, navigator}) => { test('sw: register: registerSW: https self-signed', async (t, {location, navigator}) => { Object.assign(location, { - protocol: 'https', + protocol: 'https:', hostname: 'self-signed.badssl.com', }); diff --git a/common/cloudfunc.spec.js b/common/cloudfunc.spec.js index dca24b5a..b679feeb 100644 --- a/common/cloudfunc.spec.js +++ b/common/cloudfunc.spec.js @@ -2,12 +2,17 @@ import {readFileSync} from 'node:fs'; import test from 'supertape'; import {montag} from 'montag'; import * as cheerio from 'cheerio'; +import {tryCatch} from 'try-catch'; import { _getSize, getPathLink, buildFromJSON, _getDataName, dateFormatter, + formatMsg, + getTitle, + getDotDot, + getHeaderField, } from '#common/cloudfunc'; const templatePath = new URL('../tmpl/fs', import.meta.url).pathname; @@ -221,6 +226,97 @@ test('cloudfunc: _getDataName', (t) => { t.end(); }); +test('cloudfunc: formatMsg: name', (t) => { + const result = formatMsg('hello', 'world'); + const expected = 'hello: ok("world")'; + + t.equal(result, expected, 'should format message with name'); + t.end(); +}); + +test('cloudfunc: formatMsg: no name', (t) => { + const result = formatMsg('hello'); + const expected = 'hello: ok'; + + t.equal(result, expected, 'should format message without name'); + t.end(); +}); + +test('cloudfunc: getTitle: no options', (t) => { + const result = getTitle(); + + t.ok(result, 'should return a title string even without options'); + t.end(); +}); + +test('cloudfunc: getTitle: with name', (t) => { + const result = getTitle({ + name: 'MyName', + }); + + t.match(result, 'MyName', 'should return title with name'); + t.end(); +}); + +test('cloudfunc: getHeaderField: sort not name', (t) => { + const result = getHeaderField('size', 'asc', 'name'); + + t.equal(result, 'name', 'should return plain name when sort does not match'); + t.end(); +}); + +test('cloudfunc: getHeaderField: sort name asc', (t) => { + const result = getHeaderField('name', 'asc', 'name'); + + t.equal(result, 'name', 'should return plain name when name asc'); + t.end(); +}); + +test('cloudfunc: getDotDot: root', (t) => { + const result = getDotDot('/'); + + t.equal(result, '/', 'should return / for root path'); + t.end(); +}); + +test('cloudfunc: getPathLink: /a/b/c', (t) => { + const {pathLink} = template; + const result = getPathLink('/a/b/c/', '', pathLink); + + t.ok(result, 'should build path link for 3-segment path'); + t.end(); +}); + +test('cloudfunc: getPathLink: no url', (t) => { + const [error] = tryCatch(getPathLink); + + t.equal(error.message, 'url could not be empty!', 'should throw when url is empty'); + t.end(); +}); + +test('cloudfunc: getPathLink: no template', (t) => { + const [error] = tryCatch(getPathLink, '/'); + + t.equal(error.message, 'template could not be empty!', 'should throw when template is empty'); + t.end(); +}); + +test('cloudfunc: getHeaderField: sort name desc', (t) => { + const result = getHeaderField('name', 'desc', 'name'); + const expected = 'name↓'; + + t.equal(result, expected, 'should return name with down arrow'); + t.end(); +}); + +test('cloudfunc: getDotDot: normal path', (t) => { + const result = getDotDot('/hello/world/'); + const expected = '/hello'; + + t.equal(result, expected, 'should return parent directory'); + t.end(); +}); + test('cloudfunc: buildFromJSON: formatDate', (t) => { const data = { path: '/media/', diff --git a/common/util.spec.js b/common/util.spec.js index 6a32848c..5a19a8be 100644 --- a/common/util.spec.js +++ b/common/util.spec.js @@ -5,6 +5,8 @@ import { getRegExp, escapeRegExp, getExt, + time, + timeEnd, } from '#common/util'; test('getExt: no extension', (t) => { @@ -106,11 +108,31 @@ test('util: getRegExp: no', (t) => { }); test('util: escapeRegExp: no str', (t) => { - t.equal(escapeRegExp(1), 1); + const result = escapeRegExp(1); + const expected = 1; + + t.equal(result, expected); t.end(); }); test('util: escapeRegExp', (t) => { - t.equal(escapeRegExp('#hello'), '\\#hello'); + const result = escapeRegExp('#hello'); + const expected = '\\#hello'; + + t.equal(result, expected); + t.end(); +}); + +test('util: time', (t) => { + const [error] = tryCatch(time, 'test'); + + t.notOk(error, 'should not throw'); + t.end(); +}); + +test('util: timeEnd', (t) => { + const [error] = tryCatch(timeEnd, 'test'); + + t.notOk(error, 'should not throw'); t.end(); }); diff --git a/docker/Dockerfile.io b/docker/Dockerfile.io index 7b3a9304..d5a24459 100644 --- a/docker/Dockerfile.io +++ b/docker/Dockerfile.io @@ -11,19 +11,20 @@ COPY package.json /usr/local/share/cloudcmd/ ENV DEBIAN_FRONTEND=noninteractive \ NVM_DIR=/usr/local/share/nvm \ - PREFIX=/usr/local \ npm_config_cache=/tmp/npm-cache \ GOPATH=/usr/local/share/go \ PATH=/usr/local/share/bun/bin:$PATH \ BUN_INSTALL=/usr/local/share/bun \ NPM_CONFIG_CACHE=/tmp/.npm \ + NPM_CONFIG_PREFIX=/usr/local \ + NPM_CONFIG_PACKAGE_LOCK=false \ PALABRA_DIR=/usr/local/share \ XDG_CONFIG_HOME=/usr/local/etc ARG UBUNTU_DEPS="libatomic1 curl wget git net-tools iproute2 software-properties-common" ARG RUST_DEPS="build-essential" ARG DEPS="pv gcc gdb strace upx-ucl less ffmpeg net-tools netcat-openbsd mc far2l iputils-ping vim bat fzf locales sudo command-not-found ncdu aptitude htop btop hexyl tmux" -ARG PALABRA_DEPS="nvm node rust go deno fasm nvchad rizin yara gdu f4" +ARG PALABRA_DEPS="nvm node rust go deno fasm nvchad rizin yara gdu f4 typos shellcheck gh" ARG BUN_DEPS="palabra wisdom nupdate version-io redrun superc8 supertape madrun redlint putout renamify-cli runny redfork cline" RUN apt-get update && \ @@ -31,7 +32,7 @@ RUN apt-get update && \ apt-get autoremove && \ apt-get install -y ${UBUNTU_DEPS} ${RUST_DEPES} ${DEPS} && \ echo "> Install git" && \ - #add-apt-repository ppa:git-core/ppa -y && \ + add-apt-repository ppa:git-core/ppa -y && \ echo "> Update command-not-found database. Run 'sudo apt update' to populate it." && \ apt-get update && \ apt-get upgrade -y && \ diff --git a/html/index.html b/html/index.html index 76043d9c..d366ed1c 100644 --- a/html/index.html +++ b/html/index.html @@ -9,7 +9,6 @@ - @@ -45,7 +44,7 @@