diff --git a/.github/workflows/docker-io.yml b/.github/workflows/docker-io.yml
new file mode 100644
index 00000000..8cbeb042
--- /dev/null
+++ b/.github/workflows/docker-io.yml
@@ -0,0 +1,60 @@
+name: Docker IO
+permissions:
+ contents: write
+on:
+ - push
+jobs:
+ buildx:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - name: Use Node.js 24.x
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.x
+ - name: Install Redrun
+ run: bun i redrun -g --no-save
+ - name: NPM Install
+ run: bun i --no-save
+ - name: Lint
+ run: redrun lint
+ - name: Build
+ id: build
+ run: |
+ redrun build
+ VERSION=$(grep '"version":' package.json -m1 | cut -d\" -f4)
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v4
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+ - name: Login to DockerHub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_TOKEN }}
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build and push io-image
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: docker/Dockerfile.io
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: |
+ coderaiser/cloudcmd:io
+ coderaiser/cloudcmd:${{ steps.build.outputs.version }}-io
+ ghcr.io/${{ github.repository }}-io
+ ghcr.io/${{ github.repository }}:${{ steps.build.outputs.version }}-io
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index f687d5ec..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:
@@ -84,15 +86,3 @@ jobs:
coderaiser/cloudcmd:${{ steps.build.outputs.version }}-slim
ghcr.io/${{ github.repository }}-slim
ghcr.io/${{ github.repository }}:${{ steps.build.outputs.version }}-slim
- - name: Build and push io-image
- uses: docker/build-push-action@v7
- with:
- context: .
- file: docker/Dockerfile.io
- platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- coderaiser/cloudcmd:io
- coderaiser/cloudcmd:${{ steps.build.outputs.version }}-io
- ghcr.io/${{ github.repository }}-io
- ghcr.io/${{ github.repository }}:${{ steps.build.outputs.version }}-io
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 0080488a..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
@@ -11,7 +13,7 @@ jobs:
node-version:
- 22.x
- 24.x
- - 25.x
+ - 26.x
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
@@ -22,26 +24,19 @@ 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@v9
+ uses: EndBug/add-and-commit@v10
continue-on-error: true
with:
message: "chore: ${{ env.NAME }}: actions: lint ☘️"
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 a895f142..b83b5923 100644
--- a/.npmignore
+++ b/.npmignore
@@ -5,13 +5,12 @@
*.cdr
*.eps
*.log
-*.loc
+*.lock
.*
manifest.yml
docker-compose.yml
-yarn.lock
now.json
app.json
bower.json
@@ -21,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/.nycrc.json b/.nycrc.json
index 36d6b4cb..1fe174f3 100644
--- a/.nycrc.json
+++ b/.nycrc.json
@@ -1,5 +1,5 @@
{
- "check-coverage": false,
+ "checkCoverage": false,
"all": false,
"exclude": [
"**/*.spec.js",
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 15a757ee..c5e0fbca 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,196 @@
+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:
+- d96f6c46 css: query: hide time on mobile
+- e7c55e05 docker: io: XDG_CONFIG_HOME
+
+feature:
+- ae1ca0f8 cloudcmd: cssnano-preset-default v8.0.1
+- 94d5096a style: owner, mode: improve
+- 807f8346 cloudcmd: superc8 v12.6.0
+- 67a95722 docker: io: cline: add
+- 68eacb91 qword: add
+- 4acd294b docker: io: tmux
+- 05c80043 cloudcmd: @supertape/loader-css v1.0.0
+- 800a6545 cloudcmd: eslint-plugin-n v18.0.1
+- a985bb36 cloudcmd: supertape v13.0.0
+- 4315ec61 docker: io XDG_CONFIG_HOME: /etc -> /usr/local/etc
+
+2026.05.03, v19.16.0
+
+feature:
+- d82d0335 client: vim: rr for rename file
+- acfa27cf docker: io: nvchad: add
+- 14f009f8 docker: io: bash-completion: add
+- c6c60146 docker: io: f4
+- 9a6b8935 docker: io: GOPATH
+- a9bc22ce cloudcmd: operation: rm useless checks
+- 36bacfe9 cloudcmd: client: key: vim: cc, mm
+- 591da25c cloudcmd: ponse v8.0.0
+- 1e1b073c docker: io: /usr/src/cloudcmd -> /usr/local/share/cloudcmd
+
+2026.04.28, v19.15.0
+
+feature:
+- 9d97343b cloudcmd: operation: rm useless checks
+- 46a88cfa cloudcmd: client: key: vim: cc, mm
+
+2026.04.28, v19.14.0
+
+feature:
+- 36a8b641 cloudcmd: ponse v8.0.0
+- 1c263c18 docker: io: /usr/src/cloudcmd -> /usr/local/share/cloudcmd
+- ea7b828c docker: io: ubuntu: resolute
+- cd6c11ac docker: io: far2l
+- c4beeec6 docker: io: gdu: add
+
+2026.04.21, v19.13.1
+
+feature:
+- d5cd11e8 cloudcmd: montag v2.0.1
+- dc94d2db docker: io: add latest git version
+- 1637beee docker: io: /usr/local/src -> /usr/local/share
+- b1bc4e73 Docker: io: pv
+- 7c0dca60 docker: io: git: master
+
+2026.04.15, v19.13.0
+
+fix:
+- 48693d9e docker: io: XDG_CACHE_HOME
+
+feature:
+- 4b2395c5 cloudcmd: Ctrl + L: logout
+- 977a8aaa docker: io: strace: add
+- 7d0098fd docker: io: XDG_CACHE_HOME=/tmp/cache
+- a89e901b actions: docker: io: add
+
+2026.04.12, v19.12.5
+
+feature:
+- 1cfc1a6f docker: io: only amd64
+
+2026.04.12, v19.12.4
+
+feature:
+- 3c2b5658 docker: io: haskell
+- d37f8cd3 docker: io: palabra: node
+
+2026.04.12, v19.12.3
+
+feature:
+- cb6cabd4 docker: io: PALABRA_DIR
+
+2026.04.11, v19.12.2
+
+feature:
+- 2ce11fa2 docker: io: get rid of haskell: to slow install
+- aa741232 docker: io: rizin, yara
+
+2026.04.09, v19.12.1
+
+feature:
+- 5bd03215 docker: io: add PREFIX
+
+2026.04.09, v19.12.0
+
+feature:
+- 13b15b7b docker: io: palabra
+
+2026.04.07, v19.11.14
+
+feature:
+- a31beab0 docker: io: rustup env
+
+2026.04.07, v19.11.13
+
+feature:
+- 6a08479a docker: io: BUN_INSTALL
+
+2026.04.06, v19.11.12
+
+feature:
+- 46c65554 docker: io: npm_config_cache
+
+2026.04.06, v19.11.11
+
+feature:
+- ae2ce388 docker: io: $DENO_DIR
+
+2026.04.06, v19.11.10
+
+feature:
+- 12ea14ac docker: io: nvm: node
+
+2026.04.06, v19.11.9
+
+feature:
+- ea96d13f docker: io: net-tools
+
+2026.04.06, v19.11.8
+
+feature:
+- 5c08565f docker: io: debian -> ubuntu
+
+2026.04.05, v19.11.7
+
+feature:
+- 36cdef37 docker: io: bookworm
+
+2026.04.05, v19.11.6
+
+feature:
+- 845f9bd1 cloudcmd: gritty v10.2.0
+
+2026.04.05, v19.11.5
+
+feature:
+- f9c513cc docker: io: hexyl: add
+
+2026.04.04, v19.11.4
+
+feature:
+- e7347d25 docker: io: neovim: apt -> github
+
+2026.04.04, v19.11.3
+
+feature:
+- 9970ff76 docker: io: btop
+- 4395a471 docker: io: $PATH: add $HOME/.local/bin
+
+2026.04.04, v19.11.2
+
+feature:
+- c40ae8e4 docker: io: htop
+- f463c5c5 docker: io: aptitude: add
+- d1032f09 docker: io: remove unused
+
2026.04.04, v19.11.1
feature:
diff --git a/HELP.md b/HELP.md
index cf320deb..2967f0d0 100644
--- a/HELP.md
+++ b/HELP.md
@@ -1,6 +1,6 @@
-# Cloud Commander v19.11.1
+# 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"
@@ -9,10 +9,14 @@
[DWORD]: https://github.com/cloudcmd/dword "Editor based on CodeMirror"
[EDWARD]: https://github.com/cloudcmd/edward "Editor based on Ace"
[DEEPWORD]: https://github.com/cloudcmd/deepword "Editor based on Monaco"
+[QWORD]: https://github.com/cloudcmd/qword "Editor based on CodeMirror 6"
[EDWARD_KEYS]: https://github.com/cloudcmd/edward/#hot-keys "Edward Hot keys"
[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.

@@ -26,7 +30,7 @@
- Server works on **Windows**, **Linux**, **Mac OS** and **Android** (with help of [Termux][TERMUX]).
- Can be used local or remotely.
- Adapts to screen size.
-- **3 built-in editors** with support of **syntax highlighting**: [Dword][DWORD], [Edward][EDWARD] and [Deepword][DEEPWORD].
+- **3 built-in editors** with support of **syntax highlighting**: [Dword][DWORD], [Edward][EDWARD] [Deepword][DEEPWORD] and [Qword][QWORD].
- [Console](https://github.com/cloudcmd/console "Console") with support of the default OS command line.
- Written in **JavaScript/Node.js**.
- Built-in archives pack: **zip** and **tar.gz**.
@@ -191,6 +195,7 @@ Then, start the server again with `cloudcmd` and reload the page.
| `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
@@ -213,24 +218,27 @@ Then, start the server again with `cloudcmd` and reload the page.
When the `--vim` option is provided, or the configuration parameter `vim` is set, the following hotkeys become available:
-|Key |Operation
-|:----------------------|:--------------------------------------------
-| `j` | navigate to next file
-| `k` | navigate to previous file
-| `dd` | remove current file
-| `G` or `$` | navigate to bottom file
-| `gg` or `^` | navigate to top file
-| `v` | visual mode
-| `y` | copy (selected in visual mode files)
-| `p` | paste files
-| `Esc` | unselect all
-| `/` | find file in current directory
-| `n` | navigate to next found file
-| `N` | navigate to previous found file
-| `md` | make directory
-| `mf` | make file
-| `tt` | show terminal
-| `e` | edit file
+| Key |Operation
+|:------------|:--------------------------------------------
+| `j` | navigate to next file
+| `k` | navigate to previous file
+| `dd` | remove current file
+| `G` or `$` | navigate to bottom file
+| `gg` or `^` | navigate to top file
+| `v` | visual mode
+| `y` | copy (selected in visual mode files)
+| `p` | paste files
+| `Esc` | unselect all
+| `/` | find file in current directory
+| `n` | navigate to next found file
+| `N` | navigate to previous found file
+| `md` | make directory
+| `mf` | make file
+| `tt` | show terminal
+| `e` | edit file
+| `cc` | copy
+| `mm` | move
+| `rr` | rename file
Commands can be joined, for example:
@@ -925,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.
-[](https://heroku.com/deploy?template=https://github.com/coderaiser/cloudcmd)
+[](https://app.instapods.com/dashboard/pods/create?app=cloudcmd&ref=cloudcmd)
## Docker
@@ -1114,6 +1122,35 @@ 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)**
+- *2026.04.28*, **[v19.14.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.14.0)**
+- *2026.04.21*, **[v19.13.1](//github.com/coderaiser/cloudcmd/releases/tag/v19.13.1)**
+- *2026.04.15*, **[v19.13.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.13.0)**
+- *2026.04.12*, **[v19.12.5](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.5)**
+- *2026.04.12*, **[v19.12.4](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.4)**
+- *2026.04.12*, **[v19.12.3](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.3)**
+- *2026.04.11*, **[v19.12.2](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.2)**
+- *2026.04.09*, **[v19.12.1](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.1)**
+- *2026.04.09*, **[v19.12.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.12.0)**
+- *2026.04.07*, **[v19.11.14](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.14)**
+- *2026.04.07*, **[v19.11.13](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.13)**
+- *2026.04.06*, **[v19.11.12](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.12)**
+- *2026.04.06*, **[v19.11.11](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.11)**
+- *2026.04.06*, **[v19.11.10](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.10)**
+- *2026.04.06*, **[v19.11.9](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.9)**
+- *2026.04.06*, **[v19.11.8](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.8)**
+- *2026.04.05*, **[v19.11.7](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.7)**
+- *2026.04.05*, **[v19.11.6](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.6)**
+- *2026.04.05*, **[v19.11.5](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.5)**
+- *2026.04.04*, **[v19.11.4](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.4)**
+- *2026.04.04*, **[v19.11.3](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.3)**
+- *2026.04.04*, **[v19.11.2](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.2)**
- *2026.04.04*, **[v19.11.1](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.1)**
- *2026.04.04*, **[v19.11.0](//github.com/coderaiser/cloudcmd/releases/tag/v19.11.0)**
- *2026.04.04*, **[v19.10.2](//github.com/coderaiser/cloudcmd/releases/tag/v19.10.2)**
diff --git a/README.md b/README.md
index ff078dab..dfe8d8c6 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Cloud Commander v19.11.1 [![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 31f659bc..283a7f11 100644
--- a/client/key/index.js
+++ b/client/key/index.js
@@ -442,6 +442,14 @@ async function _switchKey(event) {
break;
+ case KEY.L:
+ if (ctrlMeta && shift) {
+ CloudCmd.logOut();
+ event.preventDefault();
+ }
+
+ break;
+
case KEY.M:
if (ctrlMeta) {
if (config('vim'))
diff --git a/client/key/key.js b/client/key/key.js
index 66120e74..6772e34c 100644
--- a/client/key/key.js
+++ b/client/key/key.js
@@ -22,6 +22,7 @@ export const D = 68;
export const G = 71;
export const J = 74;
export const K = 75;
+export const L = 76;
export const M = 77;
export const O = 79;
export const P = 80;
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.js b/client/key/vim/index.js
index 5f67f4e4..981f6f19 100644
--- a/client/key/vim/index.js
+++ b/client/key/vim/index.js
@@ -1,5 +1,3 @@
-/* global CloudCmd */
-/* global DOM */
import vim from './vim.js';
import * as finder from './find.js';
import {
@@ -35,12 +33,13 @@ const getOperations = (event, deps) => {
prompt = globalThis.DOM.Dialog.prompt,
preventDefault = event?.preventDefault?.bind(event),
stopImmediatePropagation = event?.preventDefault?.bind(event),
- promptNewFile = DOM.promptNewFile,
+ promptNewFile = globalThis.DOM.promptNewFile,
toggleSelectedFile,
Buffer = {},
createFindNext = _createFindNext,
createFindPrevious = _createFindPrevious,
createMakeFile = _createMakeFile,
+ renameCurrent,
} = deps;
return {
@@ -56,15 +55,26 @@ const getOperations = (event, deps) => {
setCurrentByName,
}),
escape: unselectFiles,
-
+ rename: () => {
+ event.preventDefault();
+ renameCurrent();
+ },
remove: () => {
Operation.show('delete');
},
+ operationCopy: () => {
+ event.preventDefault();
+ Operation.show('copy');
+ },
+ operationMove: () => {
+ event.preventDefault();
+ Operation.show('move');
+ },
makeDirectory: () => {
event.stopImmediatePropagation();
event.preventDefault();
- DOM.promptNewDir();
+ globalThis.DOM.promptNewDir();
},
terminal: () => {
diff --git a/client/key/vim/index.spec.js b/client/key/vim/index.spec.js
index b31e3d60..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();
@@ -657,3 +658,99 @@ test('cloudcmd: client: vim: edit', async (t) => {
t.calledWithNoArgs(CloudCmd.EditFileVim.show);
t.end();
});
+
+test('cloudcmd: client: vim: rename', async (t) => {
+ const DOM = getDOM();
+ const renameCurrent = stub();
+
+ assign(DOM, {
+ renameCurrent,
+ });
+
+ const event = {
+ preventDefault: stub(),
+ };
+
+ await vim('rr', event, DOM);
+
+ 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/key/vim/vim.js b/client/key/vim/vim.js
index 0d0404c3..95a27caf 100644
--- a/client/key/vim/vim.js
+++ b/client/key/vim/vim.js
@@ -40,6 +40,9 @@ export default (key, operations = {}) => {
makeDirectory = noop,
terminal = noop,
edit = noop,
+ operationCopy = noop,
+ operationMove = noop,
+ rename = noop,
} = operations;
if (key === 'Enter')
@@ -111,11 +114,26 @@ export default (key, operations = {}) => {
return end();
}
+ if (value === 'cc') {
+ operationCopy();
+ return end();
+ }
+
+ if (value === 'mm') {
+ operationMove();
+ return end();
+ }
+
if (value === 'mf') {
makeFile();
return end();
}
+ if (value === 'rr') {
+ rename();
+ return end();
+ }
+
if (key === 'd' && (visual() || prevStore === 'd')) {
stopVisual();
remove();
diff --git a/client/key/vim/vim.spec.js b/client/key/vim/vim.spec.js
index 88cdd89e..02fd52d4 100644
--- a/client/key/vim/vim.spec.js
+++ b/client/key/vim/vim.spec.js
@@ -43,6 +43,28 @@ test('vim: ^', (t) => {
t.end();
});
+test('vim: cc', (t) => {
+ const operationCopy = stub();
+
+ vim('cc', {
+ operationCopy,
+ });
+
+ t.calledWithNoArgs(operationCopy);
+ t.end();
+});
+
+test('vim: mm', (t) => {
+ const operationMove = stub();
+
+ vim('mm', {
+ operationMove,
+ });
+
+ t.calledWithNoArgs(operationMove);
+ t.end();
+});
+
test('vim: w', (t) => {
const moveNext = stub();
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/edit.js b/client/modules/edit.js
index 1b726284..593a11cb 100644
--- a/client/modules/edit.js
+++ b/client/modules/edit.js
@@ -1,5 +1,5 @@
/* global CloudCmd */
-import montag from 'montag';
+import {montag} from 'montag';
import {promisify} from 'es6-promisify';
import {tryToCatch} from 'try-to-catch';
import createElement from '@cloudcmd/create-element';
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/operation/set-listeners.js b/client/modules/operation/set-listeners.js
index 431c7791..e54e7d14 100644
--- a/client/modules/operation/set-listeners.js
+++ b/client/modules/operation/set-listeners.js
@@ -15,15 +15,12 @@ export const setListeners = (options) => (emitter) => {
} = options;
let done;
- let lastError;
const onAbort = wraptile(({emitter, operation}) => {
emitter.abort();
const msg = `${operation} aborted`;
- lastError = true;
-
Dialog.alert(msg, {
cancel: false,
});
@@ -40,13 +37,10 @@ export const setListeners = (options) => (emitter) => {
operation,
}));
- let noProgress = true;
-
const listeners = {
progress: (value) => {
done = value === 100;
progress.setProgress(value);
- noProgress = false;
},
end: () => {
@@ -54,13 +48,10 @@ export const setListeners = (options) => (emitter) => {
forEachKey(removeListener, listeners);
progress.remove();
- if (lastError || done || noProgress)
- callback();
+ callback();
},
error: async (error) => {
- lastError = error;
-
if (noContinue) {
listeners.end(error);
Dialog.alert(error);
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 51408a87..b679feeb 100644
--- a/common/cloudfunc.spec.js
+++ b/common/cloudfunc.spec.js
@@ -1,13 +1,18 @@
import {readFileSync} from 'node:fs';
import test from 'supertape';
-import montag from 'montag';
+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/css/query.css b/css/query.css
index b5843b85..509f1b45 100644
--- a/css/query.css
+++ b/css/query.css
@@ -161,8 +161,9 @@
.size,
.date,
.owner,
+ .time,
.mode {
- display: none;
+ display: none !important;
}
.name {
diff --git a/css/style.css b/css/style.css
index 333075ed..10820444 100644
--- a/css/style.css
+++ b/css/style.css
@@ -235,7 +235,8 @@ a:active {
.owner {
display: inline-block;
- width: 13%;
+ width: 12%;
+
/* when inline-block
* vertical align should be
* set top to prevent additional
@@ -247,7 +248,7 @@ a:active {
.mode {
float: right;
- width: 18%;
+ width: 22%;
color: var(--column-color);
}
diff --git a/docker/Dockerfile.io b/docker/Dockerfile.io
index 6e70f325..d5a24459 100644
--- a/docker/Dockerfile.io
+++ b/docker/Dockerfile.io
@@ -1,63 +1,79 @@
-FROM node
+FROM ubuntu:resolute
LABEL maintainer="Coderaiser"
LABEL org.opencontainers.image.source="https://github.com/coderaiser/cloudcmd"
-RUN mkdir -p /usr/src/cloudcmd
+RUN mkdir -p /usr/local/share/cloudcmd
-WORKDIR /usr/src/cloudcmd
+WORKDIR /usr/local/share/cloudcmd
-COPY package.json /usr/src/cloudcmd/
+COPY package.json /usr/local/share/cloudcmd/
ENV DEBIAN_FRONTEND=noninteractive \
- PATH=/usr/local/src/cargo/bin:$PATH
+ NVM_DIR=/usr/local/share/nvm \
+ 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 GO_VERSION=1.21.2
+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 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 && \
apt-get upgrade -y && \
apt-get autoremove && \
- apt-get install -y less ffmpeg net-tools netcat-openbsd mc iputils-ping vim neovim bat fzf locales sudo command-not-found ncdu && \
+ apt-get install -y ${UBUNTU_DEPS} ${RUST_DEPES} ${DEPS} && \
+ echo "> Install git" && \
+ 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 && \
apt-get autoremove && \
apt-get clean && \
- echo "> install nvm" && \
- curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \
- mv ~/.nvm /usr/local/src/nvm && \
+ echo "> create user" && \
+ useradd -m -s /bin/bash -u 1337 instalador && \
+ chown -R instalador /usr/local && \
+ chown -R instalador /tmp
+
+USER instalador
+
+RUN echo "> install bun" && \
+ curl https://bun.sh/install | bash && \
echo "> install npm globals" && \
- npm i wisdom nupdate version-io redrun superc8 supertape madrun redlint putout renamify-cli runny redfork -g && \
- echo "> install bun" && \
- curl -fsSL https://bun.sh/install | bash && \
- mv ~/.bun /usr/local/src/bun && \
- ln -s /usr/local/src/bun/bin/bun /usr/local/bin/bun && \
- echo "> install deno" && \
- curl -fsSL https://deno.land/install.sh | sh && \
- mv ~/.deno /usr/local/src/deno && \
- ln -s /usr/local/src/deno/bin/deno /usr/local/bin/deno && \
- echo "> install golang" && \
- curl -fsSL https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz -o go.tar.gz && \
- tar -C /usr/local/src -xzf go.tar.gz && \
- rm go.tar.gz && \
- ln -s /usr/local/src/go/bin/go /usr/local/bin/go && \
- ln -s /usr/local/src/go/bin/gofmt /usr/local/bin/gofmt && \
- echo "> install rust" && \
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
- mv ~/.cargo /usr/local/src/cargo && \
- rustup default stable && \
- mv ~/.rustup /usr/local/src/rustup && \
+ bun i ${BUN_DEPS} -g && \
+ echo "> install rust go deno bun fasm nvim" && \
+ bun ${BUN_INSTALL}/bin/palabra i ${PALABRA_DEPS} && \
+ echo "> install node" && \
+ . $NVM_DIR/nvm.sh
+
+USER root
+
+RUN echo "> remove user" && \
+ userdel -r instalador && \
echo "> install gritty" && \
bun r gritty --omit dev && \
bun i gritty --omit dev && \
bun pm cache rm && \
+ echo "> setup cloudcmd" && \
+ ln -s /usr/local/share/cloudcmd/bin/cloudcmd.js /usr/local/bin/cloudcmd && \
echo "> setup git" && \
git config --global core.whitespace -trailing-space && \
git config --global pull.rebase true && \
+ git config --global init.defaultBranch master && \
echo "> configure bash" && \
echo "alias ls='ls --color=auto'" >> /etc/bash.bashrc && \
echo "alias buni='bun i --no-save'" >> /etc/bash.bashrc && \
echo "alias bat='batcat'" >> /etc/bash.bashrc && \
- echo ". /usr/local/src/nvm/nvm.sh" >> /etc/bash.bashrc && \
+ echo ". /usr/local/share/nvm/nvm.sh" >> /etc/bash.bashrc && \
+ echo ". /usr/share/bash-completion/completions/git" >> /etc/bash.bashrc && \
echo 'PS1="\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ "' >> /etc/bash.bashrc && \
echo "> setup inputrc" && \
echo "set editing-mode vi" >> /etc/inputrc && \
@@ -72,21 +88,26 @@ RUN apt-get update && \
echo "el_GR.UTF-8 UTF-8" >> /etc/locale.gen && \
locale-gen
-COPY . /usr/src/cloudcmd
+COPY . /usr/local/share/cloudcmd
WORKDIR /
ENV cloudcmd_terminal=true \
cloudcmd_terminal_path=gritty \
+ cloudcmd_vim=true \
cloudcmd_open=false \
PATH=node_modules/.bin:$PATH \
+ PATH=~/.local/bin:$PATH \
BUN_INSTALL_CACHE_DIR=/tmp/bun-cache \
DENO_DIR=/tmp/deno-cache \
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
- TERM=xterm-256color
+ TERM=xterm-256color \
+ XDG_CACHE_HOME=/tmp \
+ XDG_DATA_HOME=/usr/local/share \
+ XDG_CONFIG_HOME=~/.config
EXPOSE 8000
-ENTRYPOINT ["/usr/src/cloudcmd/bin/cloudcmd.js"]
+ENTRYPOINT ["/usr/local/share/cloudcmd/bin/cloudcmd.js"]
diff --git a/eslint.config.js b/eslint.config.js
index b38d3e2e..22add654 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,6 +1,5 @@
import {safeAlign} from 'eslint-plugin-putout';
import {defineConfig} from 'eslint/config';
-import n from 'eslint-plugin-n';
import globals from 'globals';
import {matchToFlat} from '@putout/eslint-flat';
@@ -12,23 +11,15 @@ export const match = {
'client/dom/index.*': {
'no-multi-spaces': 'off',
},
- '{client,static}/**/*.{js,mjs}': {
- 'n/no-extraneous-require': 'off',
+ 'client/**': {
'n/no-unsupported-features/node-builtins': 'off',
},
- 'bin/cloudcmd.js': {
- 'no-console': 'off',
- },
};
export default defineConfig([
safeAlign, {
ignores: ['**/fixture'],
rules: {
'key-spacing': 'off',
- 'n/prefer-node-protocol': 'error',
- },
- plugins: {
- n,
},
}, {
files: ['{client,common,static}/**/*.js'],
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 @@