From 59e187444eb3d8d092dd31599646407dea1d72a1 Mon Sep 17 00:00:00 2001
From: Luna
Date: Tue, 23 Jul 2024 16:23:40 +0530
Subject: [PATCH 1/5] switch to biome from eslint The biome config was
automatically generated from the eslintrc by the biome tool.
---
.eslintignore | 2 -
.eslintrc.json | 81 -----------------------------
biome.jsonc | 136 +++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 120 ++++++++++++++++++++-----------------------
4 files changed, 192 insertions(+), 147 deletions(-)
delete mode 100644 .eslintignore
delete mode 100644 .eslintrc.json
create mode 100644 biome.jsonc
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 85f5a45f..00000000
--- a/.eslintignore
+++ /dev/null
@@ -1,2 +0,0 @@
-node_modules/*
-pkg/lib/*
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 862472ae..00000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "root": true,
- "env": {
- "browser": true,
- "es6": true
- },
- "extends": ["eslint:recommended", "standard", "standard-jsx", "standard-react", "plugin:jsx-a11y/recommended"],
- "parserOptions": {
- "ecmaVersion": 2022
- },
- "plugins": ["react", "react-hooks", "jsx-a11y"],
- "rules": {
- "array-bracket-newline": ["error", { "multiline": true }],
- "import/extensions": ["error", "never", { "json": "always" }],
- "import/order": ["error",
- {
- "alphabetize": { "order": "asc" },
- "groups": ["builtin", "external", "internal", "parent", "sibling"],
- "newlines-between": "always",
- "pathGroupsExcludedImportTypes": ["react"],
- "pathGroups": [
- { "pattern": "react", "group": "builtin", "position": "before" }
- ]
- }],
- "indent": ["error", 4,
- {
- "ObjectExpression": "first",
- "CallExpression": {"arguments": "first"},
- "MemberExpression": 2,
- "ignoredNodes": [ "JSXAttribute" ]
- }],
- "function-paren-newline": ["error", "consistent"],
- "space-before-function-paren": "off",
- "max-len": ["error", { "code": 120 }],
- "max-statements-per-line": ["error", { "max": 1 }],
- "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }],
- "no-var": "error",
- "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
- "prefer-promise-reject-errors": ["error", { "allowEmptyReject": true }],
- "react/jsx-indent": ["error", 4],
- "semi": ["error", "always", { "omitLastInOneLineBlock": true }],
-
- "react-hooks/rules-of-hooks": "error",
- "react-hooks/exhaustive-deps": "error",
-
- "camelcase": "off",
- "comma-dangle": "off",
- "curly": "off",
- "jsx-quotes": "off",
- "key-spacing": "off",
- "no-console": "off",
- "quotes": "off",
- "react/prop-types": "off",
- "react/jsx-handler-names": "off",
- "react/jsx-max-props-per-line": [1, { "maximum": 2 }],
- "react/jsx-no-useless-fragment": "error",
-
- "jsx-a11y/anchor-is-valid": "off"
- },
- "globals": {
- "require": "readonly",
- "module": "readonly"
- },
- "overrides": [
- {
- "files": ["**/*.ts", "**/*.tsx"],
- "plugins": [
- "@typescript-eslint"
- ],
- "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "project": ["./tsconfig.json"]
- }
- }],
- "settings": {
- "import/resolver": {
- "node": { "moduleDirectory": [ "pkg/lib" ], "extensions": [ ".js", ".jsx", ".ts", ".tsx" ] }
- }
- }
-}
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 00000000..6e9bb857
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,136 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
+ "organizeImports": { "enabled": true },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": false,
+ "a11y": {
+ "noAccessKey": "error",
+ "noAriaUnsupportedElements": "error",
+ "noAutofocus": "error",
+ "noBlankTarget": "error",
+ "noDistractingElements": "error",
+ "noHeaderScope": "error",
+ "noInteractiveElementToNoninteractiveRole": "error",
+ "noNoninteractiveElementToInteractiveRole": "error",
+ "noNoninteractiveTabindex": "error",
+ "noPositiveTabindex": "error",
+ "noRedundantAlt": "error",
+ "noRedundantRoles": "error",
+ "useAltText": "error",
+ "useAnchorContent": "error",
+ "useAriaActivedescendantWithTabindex": "error",
+ "useAriaPropsForRole": "error",
+ "useHeadingContent": "error",
+ "useHtmlLang": "error",
+ "useIframeTitle": "error",
+ "useKeyWithClickEvents": "error",
+ "useKeyWithMouseEvents": "error",
+ "useMediaCaption": "error",
+ "useValidAnchor": "off",
+ "useValidAriaProps": "error",
+ "useValidAriaRole": "error",
+ "useValidAriaValues": "error"
+ },
+ "complexity": {
+ "noExtraBooleanCast": "error",
+ "noMultipleSpacesInRegularExpressionLiterals": "error",
+ "noUselessCatch": "error",
+ "noUselessConstructor": "error",
+ "noUselessFragments": "error",
+ "noUselessLoneBlockStatements": "error",
+ "noUselessRename": "error",
+ "noUselessTernary": "error",
+ "noVoid": "error",
+ "noWith": "error",
+ "useLiteralKeys": "error",
+ "useRegexLiterals": "error"
+ },
+ "correctness": {
+ "noChildrenProp": "error",
+ "noConstAssign": "error",
+ "noConstantCondition": "error",
+ "noEmptyCharacterClassInRegex": "error",
+ "noEmptyPattern": "error",
+ "noGlobalObjectCalls": "error",
+ "noInnerDeclarations": "error",
+ "noInvalidConstructorSuper": "error",
+ "noInvalidUseBeforeDeclaration": "error",
+ "noNewSymbol": "error",
+ "noNonoctalDecimalEscape": "error",
+ "noPrecisionLoss": "error",
+ "noSelfAssign": "error",
+ "noSetterReturn": "error",
+ "noSwitchDeclarations": "error",
+ "noUndeclaredVariables": "error",
+ "noUnreachable": "error",
+ "noUnreachableSuper": "error",
+ "noUnsafeFinally": "error",
+ "noUnsafeOptionalChaining": "error",
+ "noUnusedLabels": "error",
+ "noUnusedVariables": "error",
+ "useArrayLiterals": "error",
+ "useExhaustiveDependencies": "error",
+ "useHookAtTopLevel": "error",
+ "useIsNan": "error",
+ "useJsxKeyInIterable": "error",
+ "useValidForDirection": "error",
+ "useYield": "error"
+ },
+ "security": {
+ "noDangerouslySetInnerHtmlWithChildren": "error",
+ "noGlobalEval": "error"
+ },
+ "style": {
+ "noCommaOperator": "error",
+ "noImplicitBoolean": "error",
+ "noVar": "error",
+ "useBlockStatements": "off",
+ "useConst": "error",
+ "useFragmentSyntax": "error",
+ "useSingleVarDeclarator": "error"
+ },
+ "suspicious": {
+ "noAssignInExpressions": "error",
+ "noAsyncPromiseExecutor": "error",
+ "noCatchAssign": "error",
+ "noClassAssign": "error",
+ "noCommentText": "error",
+ "noCompareNegZero": "error",
+ "noConfusingLabels": "error",
+ "noConsoleLog": "off",
+ "noControlCharactersInRegex": "error",
+ "noDebugger": "error",
+ "noDoubleEquals": "error",
+ "noDuplicateCase": "error",
+ "noDuplicateClassMembers": "error",
+ "noDuplicateJsxProps": "error",
+ "noDuplicateObjectKeys": "error",
+ "noDuplicateParameters": "error",
+ "noEmptyBlockStatements": "error",
+ "noFallthroughSwitchClause": "error",
+ "noFunctionAssign": "error",
+ "noGlobalAssign": "error",
+ "noImportAssign": "error",
+ "noMisleadingCharacterClass": "error",
+ "noPrototypeBuiltins": "error",
+ "noRedeclare": "error",
+ "noSelfCompare": "error",
+ "noShadowRestrictedNames": "error",
+ "noUnsafeNegation": "error",
+ "useDefaultSwitchClauseLast": "error",
+ "useGetterReturn": "error",
+ "useValidTypeof": "error"
+ }
+ },
+ "ignore": ["node_modules/*", "pkg/lib/*", "bots/*"]
+ },
+ "javascript": {
+ "globals": ["require", "module", "document", "navigator", "window"]
+ },
+ "overrides": [{ "include": ["**/*.ts", "**/*.tsx"] }],
+ "formatter": {
+ "ignore": ["node_modules/*", "pkg/lib/*", "bots/*"]
+ }
+}
diff --git a/package.json b/package.json
index 7fab9d0b..73545a3e 100644
--- a/package.json
+++ b/package.json
@@ -1,66 +1,58 @@
{
- "name": "files",
- "description": "Scaffolding for a cockpit module",
- "type": "module",
- "main": "index.js",
- "repository": "git@github.com:cockpit-project/cockpit-files.git",
- "author": "",
- "license": "LGPL-2.1",
- "scripts": {
- "watch": "./build.js -w",
- "build": "./build.js",
- "eslint": "eslint --ext .js --ext .jsx --ext .ts --ext .tsx src/",
- "eslint:fix": "eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx src/",
- "stylelint": "stylelint src/*{.css,scss}",
- "stylelint:fix": "stylelint --fix src/*{.css,scss}"
- },
- "devDependencies": {
- "@types/react": "18.3.3",
- "@types/react-dom": "18.3.0",
- "@typescript-eslint/eslint-plugin": "7.16.1",
- "argparse": "2.0.1",
- "chrome-remote-interface": "0.33.0",
- "esbuild": "0.23.0",
- "esbuild-plugin-copy": "2.1.1",
- "esbuild-plugin-replace": "1.4.0",
- "esbuild-sass-plugin": "3.3.1",
- "esbuild-wasm": "0.23.0",
- "eslint": "8.57.0",
- "eslint-config-standard": "17.1.0",
- "eslint-config-standard-jsx": "11.0.0",
- "eslint-config-standard-react": "13.0.0",
- "eslint-plugin-import": "2.29.1",
- "eslint-plugin-jsx-a11y": "6.9.0",
- "eslint-plugin-node": "11.1.0",
- "eslint-plugin-promise": "6.5.1",
- "eslint-plugin-react": "7.35.0",
- "eslint-plugin-react-hooks": "4.6.2",
- "gettext-parser": "8.0.0",
- "htmlparser": "1.7.7",
- "jed": "1.1.1",
- "language-map": "1.5.0",
- "mime-db": "1.52.0",
- "sass": "1.77.6",
- "sizzle": "2.3.10",
- "stylelint": "16.3.1",
- "stylelint-config-recommended-scss": "14.0.0",
- "stylelint-config-standard": "36.0.1",
- "stylelint-config-standard-scss": "13.1.0",
- "stylelint-formatter-pretty": "4.0.0",
- "stylelint-use-logical-spec": "^5.0.1",
- "tsx": "4.16.0",
- "typescript": "5.5.3"
- },
- "dependencies": {
- "@patternfly/patternfly": "5.3.1",
- "@patternfly/react-core": "5.3.4",
- "@patternfly/react-icons": "5.3.2",
- "@patternfly/react-styles": "5.3.1",
- "@patternfly/react-table": "5.3.4",
- "@patternfly/react-tokens": "5.3.1",
- "dequal": "2.0.3",
- "react": "18.3.1",
- "react-dom": "18.3.1",
- "throttle-debounce": "5.0.0"
- }
+ "name": "files",
+ "description": "Scaffolding for a cockpit module",
+ "type": "module",
+ "main": "index.js",
+ "repository": "git@github.com:cockpit-project/cockpit-files.git",
+ "author": "",
+ "license": "LGPL-2.1",
+ "scripts": {
+ "watch": "./build.js -w",
+ "build": "./build.js",
+ "lint": "biome lint src/*",
+ "lint:fix": "biome lint src/* --fix",
+ "format": "biome format src/* --fix",
+ "stylelint": "stylelint src/*{.css,scss}",
+ "stylelint:fix": "stylelint --fix src/*{.css,scss}"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "1.8.3",
+ "@types/react": "18.3.3",
+ "@types/react-dom": "18.3.0",
+ "@typescript-eslint/eslint-plugin": "7.16.1",
+ "argparse": "2.0.1",
+ "chrome-remote-interface": "0.33.0",
+ "esbuild": "0.23.0",
+ "esbuild-plugin-copy": "2.1.1",
+ "esbuild-plugin-replace": "1.4.0",
+ "esbuild-sass-plugin": "3.3.1",
+ "esbuild-wasm": "0.23.0",
+ "gettext-parser": "8.0.0",
+ "htmlparser": "1.7.7",
+ "jed": "1.1.1",
+ "language-map": "1.5.0",
+ "mime-db": "1.52.0",
+ "sass": "1.77.6",
+ "sizzle": "2.3.10",
+ "stylelint": "16.3.1",
+ "stylelint-config-recommended-scss": "14.0.0",
+ "stylelint-config-standard": "36.0.1",
+ "stylelint-config-standard-scss": "13.1.0",
+ "stylelint-formatter-pretty": "4.0.0",
+ "stylelint-use-logical-spec": "^5.0.1",
+ "tsx": "4.16.0",
+ "typescript": "5.5.3"
+ },
+ "dependencies": {
+ "@patternfly/patternfly": "5.3.1",
+ "@patternfly/react-core": "5.3.4",
+ "@patternfly/react-icons": "5.3.2",
+ "@patternfly/react-styles": "5.3.1",
+ "@patternfly/react-table": "5.3.4",
+ "@patternfly/react-tokens": "5.3.1",
+ "dequal": "2.0.3",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "throttle-debounce": "5.0.0"
+ }
}
From 635a9b8df58ad7a95cd34577eb9d8c81eaff11e2 Mon Sep 17 00:00:00 2001
From: Luna
Date: Tue, 23 Jul 2024 16:24:25 +0530
Subject: [PATCH 2/5] biome format this is a formating pass done by biome to
remove errors related to the formatter
---
.stylelintrc.json | 64 ++-
build.js | 224 ++++-----
node_modules | 2 +-
src/app.tsx | 339 ++++++++------
src/common.ts | 52 +--
src/dialogs/delete.tsx | 175 ++++---
src/dialogs/mkdir.tsx | 269 ++++++-----
src/dialogs/permissions.jsx | 420 +++++++++--------
src/dialogs/rename.tsx | 308 +++++++------
src/download.tsx | 34 +-
src/files-breadcrumbs.tsx | 669 +++++++++++++++------------
src/files-card-body.tsx | 897 +++++++++++++++++++-----------------
src/files-folder-view.tsx | 117 ++---
src/filetype-data.d.ts | 2 +-
src/filetype-lookup.ts | 51 +-
src/filetype-plugin.ts | 242 +++++++---
src/header.tsx | 344 ++++++++------
src/index.tsx | 4 +-
src/manifest.json | 26 +-
src/menu.tsx | 239 +++++-----
src/ownership.tsx | 65 +--
src/sidebar.tsx | 327 +++++++------
src/upload-button.tsx | 640 +++++++++++++------------
tsconfig.json | 35 +-
24 files changed, 3051 insertions(+), 2494 deletions(-)
diff --git a/.stylelintrc.json b/.stylelintrc.json
index c34c483b..931a5a92 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -1,37 +1,35 @@
{
- "extends": "stylelint-config-standard-scss",
- "plugins": [
- "stylelint-use-logical-spec"
- ],
- "rules": {
- "at-rule-empty-line-before": null,
- "declaration-empty-line-before": null,
- "custom-property-empty-line-before": null,
- "comment-empty-line-before": null,
- "scss/double-slash-comment-empty-line-before": null,
- "scss/dollar-variable-colon-space-after": null,
+ "extends": "stylelint-config-standard-scss",
+ "plugins": ["stylelint-use-logical-spec"],
+ "rules": {
+ "at-rule-empty-line-before": null,
+ "declaration-empty-line-before": null,
+ "custom-property-empty-line-before": null,
+ "comment-empty-line-before": null,
+ "scss/double-slash-comment-empty-line-before": null,
+ "scss/dollar-variable-colon-space-after": null,
- "custom-property-pattern": null,
- "declaration-block-no-duplicate-properties": null,
- "declaration-block-no-redundant-longhand-properties": null,
- "declaration-block-no-shorthand-property-overrides": null,
- "declaration-block-single-line-max-declarations": null,
- "font-family-no-duplicate-names": null,
- "function-url-quotes": null,
- "keyframes-name-pattern": null,
- "no-descending-specificity": null,
- "no-duplicate-selectors": null,
- "scss/at-extend-no-missing-placeholder": null,
- "scss/at-import-partial-extension": null,
- "scss/at-mixin-pattern": null,
- "scss/comment-no-empty": null,
- "scss/dollar-variable-pattern": null,
- "scss/double-slash-comment-whitespace-inside": null,
- "scss/no-global-function-names": null,
- "scss/operator-no-unspaced": null,
- "selector-class-pattern": null,
- "selector-id-pattern": null,
+ "custom-property-pattern": null,
+ "declaration-block-no-duplicate-properties": null,
+ "declaration-block-no-redundant-longhand-properties": null,
+ "declaration-block-no-shorthand-property-overrides": null,
+ "declaration-block-single-line-max-declarations": null,
+ "font-family-no-duplicate-names": null,
+ "function-url-quotes": null,
+ "keyframes-name-pattern": null,
+ "no-descending-specificity": null,
+ "no-duplicate-selectors": null,
+ "scss/at-extend-no-missing-placeholder": null,
+ "scss/at-import-partial-extension": null,
+ "scss/at-mixin-pattern": null,
+ "scss/comment-no-empty": null,
+ "scss/dollar-variable-pattern": null,
+ "scss/double-slash-comment-whitespace-inside": null,
+ "scss/no-global-function-names": null,
+ "scss/operator-no-unspaced": null,
+ "selector-class-pattern": null,
+ "selector-id-pattern": null,
- "liberty/use-logical-spec": "always"
- }
+ "liberty/use-logical-spec": "always"
+ }
}
diff --git a/build.js b/build.js
index a3706ecd..8e69f255 100755
--- a/build.js
+++ b/build.js
@@ -1,143 +1,153 @@
#!/usr/bin/env -S node --import tsx/esm
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import process from 'node:process';
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
-import copy from 'esbuild-plugin-copy';
+import copy from "esbuild-plugin-copy";
-import { cockpitPoEsbuildPlugin } from './pkg/lib/cockpit-po-plugin';
-import { cockpitRsyncEsbuildPlugin } from './pkg/lib/cockpit-rsync-plugin';
-import { cleanPlugin } from './pkg/lib/esbuild-cleanup-plugin';
-import { esbuildStylesPlugins } from './pkg/lib/esbuild-common';
-import { cockpitCompressPlugin } from './pkg/lib/esbuild-compress-plugin';
-import { filetype_plugin } from './src/filetype-plugin';
+import { cockpitPoEsbuildPlugin } from "./pkg/lib/cockpit-po-plugin";
+import { cockpitRsyncEsbuildPlugin } from "./pkg/lib/cockpit-rsync-plugin";
+import { cleanPlugin } from "./pkg/lib/esbuild-cleanup-plugin";
+import { esbuildStylesPlugins } from "./pkg/lib/esbuild-common";
+import { cockpitCompressPlugin } from "./pkg/lib/esbuild-compress-plugin";
+import { filetype_plugin } from "./src/filetype-plugin";
-const useWasm = os.arch() !== 'x64';
-const esbuild = (await import(useWasm ? 'esbuild-wasm' : 'esbuild')).default;
+const useWasm = os.arch() !== "x64";
+const esbuild = (await import(useWasm ? "esbuild-wasm" : "esbuild")).default;
-const production = process.env.NODE_ENV === 'production';
+const production = process.env.NODE_ENV === "production";
// List of directories to use when using import statements
-const nodePaths = ['pkg/lib'];
-const outdir = 'dist';
+const nodePaths = ["pkg/lib"];
+const outdir = "dist";
// Obtain package name from package.json
-const packageJson = JSON.parse(fs.readFileSync('package.json'));
+const packageJson = JSON.parse(fs.readFileSync("package.json"));
-const parser = (await import('argparse')).default.ArgumentParser();
+const parser = (await import("argparse")).default.ArgumentParser();
/* eslint-disable max-len */
-parser.add_argument('-r', '--rsync', { help: "rsync bundles to ssh target after build", metavar: "HOST" });
-parser.add_argument('-w', '--watch', { action: 'store_true', help: "Enable watch mode", default: process.env.ESBUILD_WATCH === "true" });
-parser.add_argument('-m', '--metafile', { help: "Enable bundle size information file", metavar: "FILE" });
+parser.add_argument("-r", "--rsync", {
+ help: "rsync bundles to ssh target after build",
+ metavar: "HOST",
+});
+parser.add_argument("-w", "--watch", {
+ action: "store_true",
+ help: "Enable watch mode",
+ default: process.env.ESBUILD_WATCH === "true",
+});
+parser.add_argument("-m", "--metafile", {
+ help: "Enable bundle size information file",
+ metavar: "FILE",
+});
/* eslint-enable max-len */
const args = parser.parse_args();
-if (args.rsync)
- process.env.RSYNC = args.rsync;
+if (args.rsync) process.env.RSYNC = args.rsync;
function notifyEndPlugin() {
- return {
- name: 'notify-end',
- setup(build) {
- let startTime;
-
- build.onStart(() => {
- startTime = new Date();
- });
-
- build.onEnd(() => {
- const endTime = new Date();
- const timeStamp = endTime.toTimeString().split(' ')[0];
- console.log(`${timeStamp}: Build finished in ${endTime - startTime} ms`);
- });
- }
- };
+ return {
+ name: "notify-end",
+ setup(build) {
+ let startTime;
+
+ build.onStart(() => {
+ startTime = new Date();
+ });
+
+ build.onEnd(() => {
+ const endTime = new Date();
+ const timeStamp = endTime.toTimeString().split(" ")[0];
+ console.log(
+ `${timeStamp}: Build finished in ${endTime - startTime} ms`,
+ );
+ });
+ },
+ };
}
// similar to fs.watch(), but recursively watches all subdirectories
function watch_dirs(dir, on_change) {
- const callback = (ev, dir, fname) => {
- // only listen for "change" events, as renames are noisy
- // ignore hidden files
- const isHidden = /^\./.test(fname);
- if (ev !== "change" || isHidden) {
- return;
- }
- on_change(path.join(dir, fname));
- };
-
- fs.watch(dir, {}, (ev, path) => callback(ev, dir, path));
-
- // watch all subdirectories in dir
- const d = fs.opendirSync(dir);
- let dirent;
-
- while ((dirent = d.readSync()) !== null) {
- if (dirent.isDirectory())
- watch_dirs(path.join(dir, dirent.name), on_change);
- }
- d.closeSync();
+ const callback = (ev, dir, fname) => {
+ // only listen for "change" events, as renames are noisy
+ // ignore hidden files
+ const isHidden = /^\./.test(fname);
+ if (ev !== "change" || isHidden) {
+ return;
+ }
+ on_change(path.join(dir, fname));
+ };
+
+ fs.watch(dir, {}, (ev, path) => callback(ev, dir, path));
+
+ // watch all subdirectories in dir
+ const d = fs.opendirSync(dir);
+ let dirent;
+
+ while ((dirent = d.readSync()) !== null) {
+ if (dirent.isDirectory())
+ watch_dirs(path.join(dir, dirent.name), on_change);
+ }
+ d.closeSync();
}
const context = await esbuild.context({
- ...!production ? { sourcemap: "linked" } : {},
- bundle: true,
- entryPoints: ['./src/index.js'],
- // Allow external font files which live in ../../static/fonts
- external: ['*.woff', '*.woff2', '*.jpg', '*.svg', '../../assets*'],
- // Move all legal comments to a .LEGAL.txt file
- legalComments: 'external',
- loader: { ".js": "jsx" },
- minify: production,
- nodePaths,
- outdir,
- metafile: !!args.metafile,
- target: ['es2020'],
- plugins: [
- cleanPlugin(),
- // Esbuild will only copy assets that are explicitly imported and used
- // in the code. This is a problem for index.html and manifest.json which are not imported
- copy({
- assets: [
- { from: ['./src/manifest.json'], to: ['./manifest.json'] },
- { from: ['./src/index.html'], to: ['./index.html'] },
- ]
- }),
- filetype_plugin,
- ...esbuildStylesPlugins,
- cockpitPoEsbuildPlugin(),
- ...production ? [cockpitCompressPlugin()] : [],
- cockpitRsyncEsbuildPlugin({ dest: packageJson.name }),
- notifyEndPlugin(),
- ]
+ ...(!production ? { sourcemap: "linked" } : {}),
+ bundle: true,
+ entryPoints: ["./src/index.js"],
+ // Allow external font files which live in ../../static/fonts
+ external: ["*.woff", "*.woff2", "*.jpg", "*.svg", "../../assets*"],
+ // Move all legal comments to a .LEGAL.txt file
+ legalComments: "external",
+ loader: { ".js": "jsx" },
+ minify: production,
+ nodePaths,
+ outdir,
+ metafile: !!args.metafile,
+ target: ["es2020"],
+ plugins: [
+ cleanPlugin(),
+ // Esbuild will only copy assets that are explicitly imported and used
+ // in the code. This is a problem for index.html and manifest.json which are not imported
+ copy({
+ assets: [
+ { from: ["./src/manifest.json"], to: ["./manifest.json"] },
+ { from: ["./src/index.html"], to: ["./index.html"] },
+ ],
+ }),
+ filetype_plugin,
+ ...esbuildStylesPlugins,
+ cockpitPoEsbuildPlugin(),
+ ...(production ? [cockpitCompressPlugin()] : []),
+ cockpitRsyncEsbuildPlugin({ dest: packageJson.name }),
+ notifyEndPlugin(),
+ ],
});
try {
- const result = await context.rebuild();
- if (args.metafile) {
- fs.writeFileSync(args.metafile, JSON.stringify(result.metafile));
- }
+ const result = await context.rebuild();
+ if (args.metafile) {
+ fs.writeFileSync(args.metafile, JSON.stringify(result.metafile));
+ }
} catch (e) {
- if (!args.watch)
- process.exit(1);
- // ignore errors in watch mode
+ if (!args.watch) process.exit(1);
+ // ignore errors in watch mode
}
if (args.watch) {
- const on_change = async path => {
- console.log("change detected:", path);
- await context.cancel();
+ const on_change = async (path) => {
+ console.log("change detected:", path);
+ await context.cancel();
- try {
- await context.rebuild();
- } catch (e) {} // ignore in watch mode
- };
+ try {
+ await context.rebuild();
+ } catch (e) {} // ignore in watch mode
+ };
- watch_dirs('src', on_change);
+ watch_dirs("src", on_change);
- // wait forever until Control-C
- await new Promise(() => {});
+ // wait forever until Control-C
+ await new Promise(() => {});
}
context.dispose();
diff --git a/node_modules b/node_modules
index f681b4a8..370c98aa 160000
--- a/node_modules
+++ b/node_modules
@@ -1 +1 @@
-Subproject commit f681b4a8fb48ae42a5791811012f26367758f052
+Subproject commit 370c98aa1c9daf0cf5da3d36ac15a2aa69a21e4f
diff --git a/src/app.tsx b/src/app.tsx
index c9bae07d..4454857f 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -20,11 +20,21 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import {
- AlertGroup, Alert, AlertVariant, AlertActionCloseButton
+ AlertGroup,
+ Alert,
+ AlertVariant,
+ AlertActionCloseButton,
} from "@patternfly/react-core/dist/esm/components/Alert";
import { Card } from "@patternfly/react-core/dist/esm/components/Card";
-import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page";
-import { Sidebar, SidebarPanel, SidebarContent } from "@patternfly/react-core/dist/esm/components/Sidebar";
+import {
+ Page,
+ PageSection,
+} from "@patternfly/react-core/dist/esm/components/Page";
+import {
+ Sidebar,
+ SidebarPanel,
+ SidebarContent,
+} from "@patternfly/react-core/dist/esm/components/Sidebar";
import { ExclamationCircleIcon } from "@patternfly/react-icons";
import cockpit from "cockpit";
@@ -36,170 +46,199 @@ import { superuser } from "superuser";
import { FilesBreadcrumbs } from "./files-breadcrumbs";
import { FilesFolderView } from "./files-folder-view";
-import filetype_data from './filetype-data';
-import { filetype_lookup } from './filetype-lookup';
+import filetype_data from "./filetype-data";
+import { filetype_lookup } from "./filetype-lookup";
import { SidebarPanelDetails } from "./sidebar";
superuser.reload_page_on_change();
interface Alert {
- key: string,
- title: string,
- variant: AlertVariant,
- detail?: string,
+ key: string;
+ title: string;
+ variant: AlertVariant;
+ detail?: string;
}
export interface FolderFileInfo extends FileInfo {
- name: string,
- to: string | null,
- category: { class: string } | null,
+ name: string;
+ to: string | null;
+ category: { class: string } | null;
}
interface FilesContextType {
- addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void,
- cwdInfo: FileInfo | null,
+ addAlert: (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => void;
+ cwdInfo: FileInfo | null;
}
export const FilesContext = React.createContext({
- addAlert: () => console.warn("FilesContext not initialized"),
- cwdInfo: null,
+ addAlert: () => console.warn("FilesContext not initialized"),
+ cwdInfo: null,
} as FilesContextType);
export const useFilesContext = () => useContext(FilesContext);
export const Application = () => {
- const { options } = usePageLocation();
- const [loading, setLoading] = useState(true);
- const [loadingFiles, setLoadingFiles] = useState(true);
- const [errorMessage, setErrorMessage] = useState("");
- const [files, setFiles] = useState([]);
- const [selected, setSelected] = useState([]);
- const [showHidden, setShowHidden] = useState(localStorage.getItem("files:showHiddenFiles") === "true");
- const [clipboard, setClipboard] = useState([]);
- const [alerts, setAlerts] = useState([]);
- const [cwdInfo, setCwdInfo] = useState(null);
-
- const currentPath = decodeURIComponent(options.path?.toString() || "");
- // the function itself is not expensive, but `path` is later used in expensive computation
- // and changing its reference value on every render causes performance issues
- const path = useMemo(() => currentPath?.split("/"), [currentPath]);
-
- useEffect(() => {
- cockpit.user().then(user => {
- if (options.path === undefined) {
- cockpit.location.replace("/", { path: encodeURIComponent(user.home) });
- }
- });
- }, [options]);
-
- useEffect(
- () => {
- if (options.path === undefined) {
- return;
- }
-
- // Reset selected when path changes
- setSelected([]);
-
- const client = new FsInfoClient(
- `/${currentPath}`,
- ["type", "mode", "size", "mtime", "user", "group", "target", "entries", "targets"],
- { superuser: 'try' }
- );
-
- const disconnect = client.on('change', (state) => {
- setLoading(false);
- setLoadingFiles(!(state.info || state.error));
- setCwdInfo(state.info || null);
- setErrorMessage(state.error?.message ?? "");
- const entries = Object.entries(state?.info?.entries || {});
- const files = entries.map(([name, attrs]) => {
- const to = FsInfoClient.target(state.info!, name)?.type ?? null;
- const category = to === 'reg' ? filetype_lookup(filetype_data, name) : null;
- return { ...attrs, name, to, category };
- });
- setFiles(files);
- });
-
- return () => {
- disconnect();
- client.close();
- };
- },
- [options, currentPath]
- );
-
- if (loading)
- return ;
-
- const addAlert = (title: string, variant: AlertVariant, key: string, detail?: string) => {
- setAlerts(prevAlerts => [...prevAlerts, { title, variant, key, ...detail && { detail }, }]);
- };
- const removeAlert = (key: string) => setAlerts(prevAlerts => prevAlerts.filter(alert => alert.key !== key));
-
- return (
-
-
-
-
- {alerts.map(alert => (
- removeAlert(alert.key)}
- />
- }
- key={alert.key}
- >
- {alert.detail}
-
- ))}
-
-
-
-
-
- {errorMessage &&
-
-
- }
- {!errorMessage &&
- }
-
-
- files.find(f => f.name === s.name))
- .filter(s => s !== undefined)}
- showHidden={showHidden} setSelected={setSelected}
- clipboard={clipboard} setClipboard={setClipboard}
- files={files}
- />
-
-
-
-
-
-
- );
+ const { options } = usePageLocation();
+ const [loading, setLoading] = useState(true);
+ const [loadingFiles, setLoadingFiles] = useState(true);
+ const [errorMessage, setErrorMessage] = useState("");
+ const [files, setFiles] = useState([]);
+ const [selected, setSelected] = useState([]);
+ const [showHidden, setShowHidden] = useState(
+ localStorage.getItem("files:showHiddenFiles") === "true",
+ );
+ const [clipboard, setClipboard] = useState([]);
+ const [alerts, setAlerts] = useState([]);
+ const [cwdInfo, setCwdInfo] = useState(null);
+
+ const currentPath = decodeURIComponent(options.path?.toString() || "");
+ // the function itself is not expensive, but `path` is later used in expensive computation
+ // and changing its reference value on every render causes performance issues
+ const path = useMemo(() => currentPath?.split("/"), [currentPath]);
+
+ useEffect(() => {
+ cockpit.user().then((user) => {
+ if (options.path === undefined) {
+ cockpit.location.replace("/", { path: encodeURIComponent(user.home) });
+ }
+ });
+ }, [options]);
+
+ useEffect(() => {
+ if (options.path === undefined) {
+ return;
+ }
+
+ // Reset selected when path changes
+ setSelected([]);
+
+ const client = new FsInfoClient(
+ `/${currentPath}`,
+ [
+ "type",
+ "mode",
+ "size",
+ "mtime",
+ "user",
+ "group",
+ "target",
+ "entries",
+ "targets",
+ ],
+ { superuser: "try" },
+ );
+
+ const disconnect = client.on("change", (state) => {
+ setLoading(false);
+ setLoadingFiles(!(state.info || state.error));
+ setCwdInfo(state.info || null);
+ setErrorMessage(state.error?.message ?? "");
+ const entries = Object.entries(state?.info?.entries || {});
+ const files = entries.map(([name, attrs]) => {
+ const to = FsInfoClient.target(state.info!, name)?.type ?? null;
+ const category =
+ to === "reg" ? filetype_lookup(filetype_data, name) : null;
+ return { ...attrs, name, to, category };
+ });
+ setFiles(files);
+ });
+
+ return () => {
+ disconnect();
+ client.close();
+ };
+ }, [options, currentPath]);
+
+ if (loading) return ;
+
+ const addAlert = (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => {
+ setAlerts((prevAlerts) => [
+ ...prevAlerts,
+ { title, variant, key, ...(detail && { detail }) },
+ ]);
+ };
+ const removeAlert = (key: string) =>
+ setAlerts((prevAlerts) => prevAlerts.filter((alert) => alert.key !== key));
+
+ return (
+
+
+
+
+ {alerts.map((alert) => (
+ removeAlert(alert.key)}
+ />
+ }
+ key={alert.key}
+ >
+ {alert.detail}
+
+ ))}
+
+
+
+
+
+ {errorMessage && (
+
+
+
+ )}
+ {!errorMessage && (
+
+ )}
+
+
+ files.find((f) => f.name === s.name))
+ .filter((s) => s !== undefined)}
+ showHidden={showHidden}
+ setSelected={setSelected}
+ clipboard={clipboard}
+ setClipboard={setClipboard}
+ files={files}
+ />
+
+
+
+
+
+
+ );
};
diff --git a/src/common.ts b/src/common.ts
index d1caaded..5a4f53e8 100644
--- a/src/common.ts
+++ b/src/common.ts
@@ -22,41 +22,41 @@ import cockpit from "cockpit";
const _ = cockpit.gettext;
export const permissions = [
- /* 0 */ _("None"),
- /* 1 */ _("Execute-only"),
- /* 2 */ _("Write-only"),
- /* 3 */ _("Write and execute"),
- /* 4 */ _("Read-only"),
- /* 5 */ _("Read and execute"),
- /* 6 */ _("Read and write"),
- /* 7 */ _("Read, write and execute"),
+ /* 0 */ _("None"),
+ /* 1 */ _("Execute-only"),
+ /* 2 */ _("Write-only"),
+ /* 3 */ _("Write and execute"),
+ /* 4 */ _("Read-only"),
+ /* 5 */ _("Read and execute"),
+ /* 6 */ _("Read and write"),
+ /* 7 */ _("Read, write and execute"),
];
export const inode_types = {
- blk: _("Block device"),
- chr: _("Character device"),
- dir: _("Directory"),
- fifo: _("Named pipe"),
- lnk: _("Symbolic link"),
- reg: _("Regular file"),
- sock: _("Socket"),
+ blk: _("Block device"),
+ chr: _("Character device"),
+ dir: _("Directory"),
+ fifo: _("Named pipe"),
+ lnk: _("Symbolic link"),
+ reg: _("Regular file"),
+ sock: _("Socket"),
};
export function get_permissions(n: number) {
- return permissions[n & 0o7];
+ return permissions[n & 0o7];
}
-export function * map_permissions(func: (value: number, label: string) => T) {
- for (const [value, label] of permissions.entries()) {
- yield func(value, label);
- }
+export function* map_permissions(func: (value: number, label: string) => T) {
+ for (const [value, label] of permissions.entries()) {
+ yield func(value, label);
+ }
}
export function basename(path: string) {
- const elements = path.split('/');
- if (elements.length === 0) {
- return '/';
- } else {
- return elements[elements.length - 1];
- }
+ const elements = path.split("/");
+ if (elements.length === 0) {
+ return "/";
+ } else {
+ return elements[elements.length - 1];
+ }
}
diff --git a/src/dialogs/delete.tsx b/src/dialogs/delete.tsx
index 2e7f2941..4f3b8f39 100644
--- a/src/dialogs/delete.tsx
+++ b/src/dialogs/delete.tsx
@@ -17,93 +17,118 @@
* along with Cockpit; If not, see .
*/
-import React, { useState } from 'react';
+import React, { useState } from "react";
-import { Button } from '@patternfly/react-core/dist/esm/components/Button';
-import { Modal, ModalVariant } from '@patternfly/react-core/dist/esm/components/Modal';
+import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import {
+ Modal,
+ ModalVariant,
+} from "@patternfly/react-core/dist/esm/components/Modal";
-import cockpit from 'cockpit';
-import { InlineNotification } from 'cockpit-components-inline-notification';
-import type { Dialogs, DialogResult } from 'dialogs';
+import cockpit from "cockpit";
+import { InlineNotification } from "cockpit-components-inline-notification";
+import type { Dialogs, DialogResult } from "dialogs";
-import type { FolderFileInfo } from '../app';
+import type { FolderFileInfo } from "../app";
const _ = cockpit.gettext;
-const ConfirmDeletionDialog = ({ dialogResult, path, selected, setSelected } : {
- dialogResult: DialogResult
- path: string,
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
+const ConfirmDeletionDialog = ({
+ dialogResult,
+ path,
+ selected,
+ setSelected,
+}: {
+ dialogResult: DialogResult;
+ path: string;
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
}) => {
- const [errorMessage, setErrorMessage] = useState(null);
- const [forceDelete, setForceDelete] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [forceDelete, setForceDelete] = useState(false);
- let modalTitle;
- if (selected.length > 1) {
- modalTitle = cockpit.format(forceDelete ? _("Force delete $0 items") : _("Delete $0 items?"), selected.length);
- } else {
- const selectedItem = selected[0];
- if (selectedItem.type === "reg") {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete file $0?") : _("Delete file $0?"), selectedItem.name
- );
- } else if (selectedItem.type === "lnk") {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete link $0?") : _("Delete link $0?"), selectedItem.name
- );
- } else if (selectedItem.type === "dir") {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete directory $0?") : _("Delete directory $0?"), selectedItem.name
- );
- } else {
- modalTitle = cockpit.format(forceDelete ? _("Force delete $0") : _("Delete $0?"), selectedItem.name);
- }
- }
+ let modalTitle;
+ if (selected.length > 1) {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete $0 items") : _("Delete $0 items?"),
+ selected.length,
+ );
+ } else {
+ const selectedItem = selected[0];
+ if (selectedItem.type === "reg") {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete file $0?") : _("Delete file $0?"),
+ selectedItem.name,
+ );
+ } else if (selectedItem.type === "lnk") {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete link $0?") : _("Delete link $0?"),
+ selectedItem.name,
+ );
+ } else if (selectedItem.type === "dir") {
+ modalTitle = cockpit.format(
+ forceDelete
+ ? _("Force delete directory $0?")
+ : _("Delete directory $0?"),
+ selectedItem.name,
+ );
+ } else {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete $0") : _("Delete $0?"),
+ selectedItem.name,
+ );
+ }
+ }
- const deleteItem = () => {
- const args = ["rm", "-r"];
- // TODO: Make force more sensible https://github.com/cockpit-project/cockpit-files/issues/363
- cockpit.spawn([...args, ...selected.map(f => path + f.name)], { err: "message", superuser: "try" })
- .then(() => {
- setSelected([]);
- dialogResult.resolve();
- })
- .catch(err => {
- setErrorMessage(err.message);
- setForceDelete(true);
- });
- };
+ const deleteItem = () => {
+ const args = ["rm", "-r"];
+ // TODO: Make force more sensible https://github.com/cockpit-project/cockpit-files/issues/363
+ cockpit
+ .spawn([...args, ...selected.map((f) => path + f.name)], {
+ err: "message",
+ superuser: "try",
+ })
+ .then(() => {
+ setSelected([]);
+ dialogResult.resolve();
+ })
+ .catch((err) => {
+ setErrorMessage(err.message);
+ setForceDelete(true);
+ });
+ };
- return (
- dialogResult.resolve()}
- footer={
- <>
- {_("Delete")}
- dialogResult.resolve()}>{_("Cancel")}
- >
- }
- >
- {errorMessage &&
- }
-
- );
+ return (
+ dialogResult.resolve()}
+ footer={
+ <>
+
+ {_("Delete")}
+
+ dialogResult.resolve()}>
+ {_("Cancel")}
+
+ >
+ }
+ >
+ {errorMessage && (
+
+ )}
+
+ );
};
export function confirm_delete(
- dialogs: Dialogs,
- path: string,
- selected: FolderFileInfo[],
- setSelected: React.Dispatch>
+ dialogs: Dialogs,
+ path: string,
+ selected: FolderFileInfo[],
+ setSelected: React.Dispatch>,
) {
- dialogs.run(ConfirmDeletionDialog, { path, selected, setSelected });
+ dialogs.run(ConfirmDeletionDialog, { path, selected, setSelected });
}
diff --git a/src/dialogs/mkdir.tsx b/src/dialogs/mkdir.tsx
index a94c96cf..8ab6f5cc 100644
--- a/src/dialogs/mkdir.tsx
+++ b/src/dialogs/mkdir.tsx
@@ -17,146 +17,173 @@
* along with this program. If not, see .
*/
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
-import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
-import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
-import { Modal, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal";
+import {
+ Form,
+ FormGroup,
+} from "@patternfly/react-core/dist/esm/components/Form";
+import {
+ FormSelect,
+ FormSelectOption,
+} from "@patternfly/react-core/dist/esm/components/FormSelect";
+import {
+ Modal,
+ ModalVariant,
+} from "@patternfly/react-core/dist/esm/components/Modal";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
-import cockpit from 'cockpit';
-import { FormHelper } from 'cockpit-components-form-helper';
-import { InlineNotification } from 'cockpit-components-inline-notification';
-import type { Dialogs, DialogResult } from 'dialogs';
-import { superuser } from 'superuser';
+import cockpit from "cockpit";
+import { FormHelper } from "cockpit-components-form-helper";
+import { InlineNotification } from "cockpit-components-inline-notification";
+import type { Dialogs, DialogResult } from "dialogs";
+import { superuser } from "superuser";
-import { useFilesContext } from '../app';
-import { get_owner_candidates } from '../ownership';
+import { useFilesContext } from "../app";
+import { get_owner_candidates } from "../ownership";
const _ = cockpit.gettext;
function check_name(candidate: string) {
- if (candidate === "") {
- return _("Directory name cannot be empty.");
- } else if (candidate.length >= 256) {
- return _("Directory name too long.");
- } else if (candidate.includes("/")) {
- return _("Directory name cannot include a /.");
- } else {
- return undefined;
- }
+ if (candidate === "") {
+ return _("Directory name cannot be empty.");
+ } else if (candidate.length >= 256) {
+ return _("Directory name too long.");
+ } else if (candidate.includes("/")) {
+ return _("Directory name cannot include a /.");
+ } else {
+ return undefined;
+ }
}
async function create_directory(path: string, owner?: string) {
- if (owner !== undefined) {
- const opts = { err: "message", superuser: "require" } as const;
- await cockpit.spawn(["mkdir", path], opts);
- await cockpit.spawn(["chown", owner, path], opts);
- } else {
- await cockpit.spawn(["mkdir", path], { err: "message" });
- }
+ if (owner !== undefined) {
+ const opts = { err: "message", superuser: "require" } as const;
+ await cockpit.spawn(["mkdir", path], opts);
+ await cockpit.spawn(["chown", owner, path], opts);
+ } else {
+ await cockpit.spawn(["mkdir", path], { err: "message" });
+ }
}
-const CreateDirectoryModal = ({ currentPath, dialogResult } : {
- currentPath: string,
- dialogResult: DialogResult
+const CreateDirectoryModal = ({
+ currentPath,
+ dialogResult,
+}: {
+ currentPath: string;
+ dialogResult: DialogResult;
}) => {
- const [name, setName] = useState("");
- const [nameError, setNameError] = useState();
- const [errorMessage, setErrorMessage] = useState();
- const [owner, setOwner] = useState();
- const [user, setUser] = useState();
- const createDirectory = () => {
- const path = currentPath + name;
- create_directory(path, owner).then(dialogResult.resolve, err => setErrorMessage(err.message));
- };
- const { cwdInfo } = useFilesContext();
+ const [name, setName] = useState("");
+ const [nameError, setNameError] = useState();
+ const [errorMessage, setErrorMessage] = useState();
+ const [owner, setOwner] = useState();
+ const [user, setUser] = useState();
+ const createDirectory = () => {
+ const path = currentPath + name;
+ create_directory(path, owner).then(dialogResult.resolve, (err) =>
+ setErrorMessage(err.message),
+ );
+ };
+ const { cwdInfo } = useFilesContext();
- useEffect(() => {
- cockpit.user().then(user => setUser(user));
- }, []);
+ useEffect(() => {
+ cockpit.user().then((user) => setUser(user));
+ }, []);
- const candidates = [];
- if (superuser.allowed && user && cwdInfo) {
- candidates.push(...get_owner_candidates(user, cwdInfo));
- if (owner === undefined) {
- setOwner(candidates[0]);
- }
- }
+ const candidates = [];
+ if (superuser.allowed && user && cwdInfo) {
+ candidates.push(...get_owner_candidates(user, cwdInfo));
+ if (owner === undefined) {
+ setOwner(candidates[0]);
+ }
+ }
- return (
- dialogResult.resolve()}
- variant={ModalVariant.small}
- footer={
- <>
-
- {_("Create")}
-
- dialogResult.resolve()}>{_("Cancel")}
- >
- }
- >
-
- {errorMessage !== undefined &&
- }
-
-
-
- );
+ return (
+ dialogResult.resolve()}
+ variant={ModalVariant.small}
+ footer={
+ <>
+
+ {_("Create")}
+
+ dialogResult.resolve()}>
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
-export function show_create_directory_dialog(dialogs: Dialogs, currentPath: string) {
- dialogs.run(CreateDirectoryModal, { currentPath });
+export function show_create_directory_dialog(
+ dialogs: Dialogs,
+ currentPath: string,
+) {
+ dialogs.run(CreateDirectoryModal, { currentPath });
}
diff --git a/src/dialogs/permissions.jsx b/src/dialogs/permissions.jsx
index 64d65c25..3595a1fb 100644
--- a/src/dialogs/permissions.jsx
+++ b/src/dialogs/permissions.jsx
@@ -17,208 +17,234 @@
* along with Cockpit; If not, see .
*/
-import React, { useState } from 'react';
-
-import { Button } from '@patternfly/react-core/dist/esm/components/Button';
-import { Form, FormGroup, FormSection } from '@patternfly/react-core/dist/esm/components/Form';
-import { FormSelect, FormSelectOption } from '@patternfly/react-core/dist/esm/components/FormSelect';
-import { Modal, ModalVariant } from '@patternfly/react-core/dist/esm/components/Modal';
-import { Stack } from '@patternfly/react-core/dist/esm/layouts/Stack';
-
-import cockpit from 'cockpit';
-import { InlineNotification } from 'cockpit-components-inline-notification';
-import { useInit } from 'hooks';
-import { etc_group_syntax, etc_passwd_syntax } from 'pam_user_parser';
-import { superuser } from 'superuser';
-
-import { useFilesContext } from '../app';
-import { map_permissions, inode_types } from '../common';
+import React, { useState } from "react";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import {
+ Form,
+ FormGroup,
+ FormSection,
+} from "@patternfly/react-core/dist/esm/components/Form";
+import {
+ FormSelect,
+ FormSelectOption,
+} from "@patternfly/react-core/dist/esm/components/FormSelect";
+import {
+ Modal,
+ ModalVariant,
+} from "@patternfly/react-core/dist/esm/components/Modal";
+import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
+
+import cockpit from "cockpit";
+import { InlineNotification } from "cockpit-components-inline-notification";
+import { useInit } from "hooks";
+import { etc_group_syntax, etc_passwd_syntax } from "pam_user_parser";
+import { superuser } from "superuser";
+
+import { useFilesContext } from "../app";
+import { map_permissions, inode_types } from "../common";
const _ = cockpit.gettext;
const EditPermissionsModal = ({ dialogResult, selected, path }) => {
- const { cwdInfo } = useFilesContext();
-
- // Nothing selected means we act on the current working directory
- if (!selected) {
- const directory_name = path[path.length - 1];
- selected = { ...cwdInfo, isCwd: true, name: directory_name };
- }
-
- const [owner, setOwner] = useState(selected.user);
- const [mode, setMode] = useState(selected.mode);
- const [group, setGroup] = useState(selected.group);
- const [errorMessage, setErrorMessage] = useState(undefined);
- const [accounts, setAccounts] = useState(null);
- const [groups, setGroups] = useState(null);
-
- useInit(async () => {
- try {
- const passwd = await cockpit.spawn(["getent", "passwd"], { err: "message" });
- setAccounts(etc_passwd_syntax.parse(passwd));
- } catch (exc) {
- console.error("Cannot obtain users from getent passwd", exc);
- }
-
- try {
- const group = await cockpit.spawn(["getent", "group"], { err: "message" });
- setGroups(etc_group_syntax.parse(group));
- } catch (exc) {
- console.error("Cannot obtain users from getent group", exc);
- }
- });
-
- const changeOwner = (owner) => {
- setOwner(owner);
- const currentOwner = accounts.find(a => a.name === owner);
- const currentGroup = groups.find(g => g.name === group);
- if (currentGroup?.gid !== currentOwner?.gid && !currentGroup?.userlist.includes(currentOwner?.name)) {
- setGroup(groups.find(g => g.gid === currentOwner.gid).name);
- }
- };
-
- const spawnEditPermissions = async () => {
- const permissionChanged = mode !== selected.mode;
- const ownerChanged = owner !== selected.user || group !== selected.group;
-
- try {
- const directory = selected?.isCwd ? path.join("/") : path.join("/") + "/" + selected.name;
- if (permissionChanged)
- await cockpit.spawn(["chmod", mode.toString(8), directory],
- { superuser: "try", err: "message" });
-
- if (ownerChanged)
- await cockpit.spawn(["chown", owner + ":" + group, directory],
- { superuser: "try", err: "message" });
-
- dialogResult.resolve();
- } catch (err) {
- setErrorMessage(err.message);
- }
- };
-
- function permissions_options() {
- return [
- ...map_permissions((value, label) => (
-
- ))
- ];
- }
-
- function sortByName(a, b) {
- return a.name.localeCompare(b.name);
- }
-
- return (
-
- spawnEditPermissions()}
- >
- {_("Change")}
-
- {_("Cancel")}
- >
- }
- >
-
- {errorMessage !== undefined &&
- }
-
-
-
- );
+ const { cwdInfo } = useFilesContext();
+
+ // Nothing selected means we act on the current working directory
+ if (!selected) {
+ const directory_name = path[path.length - 1];
+ selected = { ...cwdInfo, isCwd: true, name: directory_name };
+ }
+
+ const [owner, setOwner] = useState(selected.user);
+ const [mode, setMode] = useState(selected.mode);
+ const [group, setGroup] = useState(selected.group);
+ const [errorMessage, setErrorMessage] = useState(undefined);
+ const [accounts, setAccounts] = useState(null);
+ const [groups, setGroups] = useState(null);
+
+ useInit(async () => {
+ try {
+ const passwd = await cockpit.spawn(["getent", "passwd"], {
+ err: "message",
+ });
+ setAccounts(etc_passwd_syntax.parse(passwd));
+ } catch (exc) {
+ console.error("Cannot obtain users from getent passwd", exc);
+ }
+
+ try {
+ const group = await cockpit.spawn(["getent", "group"], {
+ err: "message",
+ });
+ setGroups(etc_group_syntax.parse(group));
+ } catch (exc) {
+ console.error("Cannot obtain users from getent group", exc);
+ }
+ });
+
+ const changeOwner = (owner) => {
+ setOwner(owner);
+ const currentOwner = accounts.find((a) => a.name === owner);
+ const currentGroup = groups.find((g) => g.name === group);
+ if (
+ currentGroup?.gid !== currentOwner?.gid &&
+ !currentGroup?.userlist.includes(currentOwner?.name)
+ ) {
+ setGroup(groups.find((g) => g.gid === currentOwner.gid).name);
+ }
+ };
+
+ const spawnEditPermissions = async () => {
+ const permissionChanged = mode !== selected.mode;
+ const ownerChanged = owner !== selected.user || group !== selected.group;
+
+ try {
+ const directory = selected?.isCwd
+ ? path.join("/")
+ : path.join("/") + "/" + selected.name;
+ if (permissionChanged)
+ await cockpit.spawn(["chmod", mode.toString(8), directory], {
+ superuser: "try",
+ err: "message",
+ });
+
+ if (ownerChanged)
+ await cockpit.spawn(["chown", owner + ":" + group, directory], {
+ superuser: "try",
+ err: "message",
+ });
+
+ dialogResult.resolve();
+ } catch (err) {
+ setErrorMessage(err.message);
+ }
+ };
+
+ function permissions_options() {
+ return [
+ ...map_permissions((value, label) => (
+
+ )),
+ ];
+ }
+
+ function sortByName(a, b) {
+ return a.name.localeCompare(b.name);
+ }
+
+ return (
+
+ spawnEditPermissions()}>
+ {_("Change")}
+
+
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
export function edit_permissions(dialogs, selected, path) {
- dialogs.run(EditPermissionsModal, { selected, path });
+ dialogs.run(EditPermissionsModal, { selected, path });
}
diff --git a/src/dialogs/rename.tsx b/src/dialogs/rename.tsx
index ef29afdf..efd18f13 100644
--- a/src/dialogs/rename.tsx
+++ b/src/dialogs/rename.tsx
@@ -17,156 +17,184 @@
* along with Cockpit; If not, see .
*/
-import React, { useState } from 'react';
-
-import { Button } from '@patternfly/react-core/dist/esm/components/Button';
-import { Form, FormGroup } from '@patternfly/react-core/dist/esm/components/Form';
-import { Modal, ModalVariant } from '@patternfly/react-core/dist/esm/components/Modal';
-import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput';
-import { Stack } from '@patternfly/react-core/dist/esm/layouts/Stack';
-
-import cockpit from 'cockpit';
+import React, { useState } from "react";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import {
+ Form,
+ FormGroup,
+} from "@patternfly/react-core/dist/esm/components/Form";
+import {
+ Modal,
+ ModalVariant,
+} from "@patternfly/react-core/dist/esm/components/Modal";
+import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
+import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
+
+import cockpit from "cockpit";
import { FileInfo } from "cockpit/fsinfo";
-import { FormHelper } from 'cockpit-components-form-helper';
-import { InlineNotification } from 'cockpit-components-inline-notification';
-import type { Dialogs, DialogResult } from 'dialogs';
-import { fmt_to_fragments } from 'utils';
+import { FormHelper } from "cockpit-components-form-helper";
+import { InlineNotification } from "cockpit-components-inline-notification";
+import type { Dialogs, DialogResult } from "dialogs";
+import { fmt_to_fragments } from "utils";
-import { FolderFileInfo, useFilesContext } from '../app';
+import { FolderFileInfo, useFilesContext } from "../app";
const _ = cockpit.gettext;
-function checkName(candidate: string, entries: Record, selectedFile: FolderFileInfo) {
- if (candidate === "") {
- return _("Name cannot be empty.");
- } else if (candidate.length >= 256) {
- return _("Name too long.");
- } else if (candidate.includes("/")) {
- return _("Name cannot include a /.");
- } else if (selectedFile.name === candidate) {
- return _("Filename is the same as original name");
- } else if (candidate in entries) {
- if (entries[candidate].type === "dir") {
- return _("Directory with the same name exists");
- }
- return _("File exists");
- } else {
- return null;
- }
+function checkName(
+ candidate: string,
+ entries: Record,
+ selectedFile: FolderFileInfo,
+) {
+ if (candidate === "") {
+ return _("Name cannot be empty.");
+ } else if (candidate.length >= 256) {
+ return _("Name too long.");
+ } else if (candidate.includes("/")) {
+ return _("Name cannot include a /.");
+ } else if (selectedFile.name === candidate) {
+ return _("Filename is the same as original name");
+ } else if (candidate in entries) {
+ if (entries[candidate].type === "dir") {
+ return _("Directory with the same name exists");
+ }
+ return _("File exists");
+ } else {
+ return null;
+ }
}
-function checkCanOverride(candidate: string, entries: Record, selectedFile: FolderFileInfo) {
- if (candidate in entries) {
- const conflictFile = entries[candidate];
- // only allow overwriting regular files
- if (conflictFile.type !== "reg") {
- return false;
- }
-
- // don't allow overwrite when the filename is unchanged
- if (selectedFile.type === "reg" && candidate !== selectedFile.name) {
- return true;
- }
- }
-
- return false;
+function checkCanOverride(
+ candidate: string,
+ entries: Record,
+ selectedFile: FolderFileInfo,
+) {
+ if (candidate in entries) {
+ const conflictFile = entries[candidate];
+ // only allow overwriting regular files
+ if (conflictFile.type !== "reg") {
+ return false;
+ }
+
+ // don't allow overwrite when the filename is unchanged
+ if (selectedFile.type === "reg" && candidate !== selectedFile.name) {
+ return true;
+ }
+ }
+
+ return false;
}
-const RenameItemModal = ({ dialogResult, path, selected } : {
- dialogResult: DialogResult
- path: string[],
- selected: FolderFileInfo,
+const RenameItemModal = ({
+ dialogResult,
+ path,
+ selected,
+}: {
+ dialogResult: DialogResult;
+ path: string[];
+ selected: FolderFileInfo;
}) => {
- const { cwdInfo } = useFilesContext();
- const [name, setName] = useState(selected.name);
- const [nameError, setNameError] = useState(null);
- const [errorMessage, setErrorMessage] = useState(undefined);
- const [overrideFileName, setOverrideFileName] = useState(false);
-
- const renameItem = (force = false) => {
- const newPath = path.join("/") + "/" + name;
- const mvCmd = ["mv", "--no-target-directory"];
- if (force) {
- mvCmd.push("--force");
- }
- mvCmd.push(path.join("/") + "/" + selected.name, newPath);
-
- cockpit.spawn(mvCmd, { superuser: "try", err: "message" })
- .then(() => {
- dialogResult.resolve();
- }, err => setErrorMessage(err.message));
- };
-
- const footer = (
- <>
- renameItem()}
- isDisabled={errorMessage !== undefined || nameError !== null}
- >
- {_("Rename")}
-
- {overrideFileName &&
- renameItem(true)}
- >
- {_("Overwrite")}
- }
- dialogResult.resolve()}>{_("Cancel")}
- >
- );
-
- const label = selected.type !== "dir" ? _("New filename") : _("New name");
-
- return (
- {selected.name})}
- variant={ModalVariant.small}
- isOpen
- onClose={() => dialogResult.resolve()}
- footer={footer}
- >
-
- {errorMessage !== undefined &&
- }
-
-
-
- );
+ const { cwdInfo } = useFilesContext();
+ const [name, setName] = useState(selected.name);
+ const [nameError, setNameError] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(
+ undefined,
+ );
+ const [overrideFileName, setOverrideFileName] = useState(false);
+
+ const renameItem = (force = false) => {
+ const newPath = path.join("/") + "/" + name;
+ const mvCmd = ["mv", "--no-target-directory"];
+ if (force) {
+ mvCmd.push("--force");
+ }
+ mvCmd.push(path.join("/") + "/" + selected.name, newPath);
+
+ cockpit.spawn(mvCmd, { superuser: "try", err: "message" }).then(
+ () => {
+ dialogResult.resolve();
+ },
+ (err) => setErrorMessage(err.message),
+ );
+ };
+
+ const footer = (
+ <>
+ renameItem()}
+ isDisabled={errorMessage !== undefined || nameError !== null}
+ >
+ {_("Rename")}
+
+ {overrideFileName && (
+ renameItem(true)}>
+ {_("Overwrite")}
+
+ )}
+ dialogResult.resolve()}>
+ {_("Cancel")}
+
+ >
+ );
+
+ const label = selected.type !== "dir" ? _("New filename") : _("New name");
+
+ return (
+ {selected.name})}
+ variant={ModalVariant.small}
+ isOpen
+ onClose={() => dialogResult.resolve()}
+ footer={footer}
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
-export function show_rename_dialog(dialogs: Dialogs, path: string[], selected: FolderFileInfo) {
- dialogs.run(RenameItemModal, { path, selected });
+export function show_rename_dialog(
+ dialogs: Dialogs,
+ path: string[],
+ selected: FolderFileInfo,
+) {
+ dialogs.run(RenameItemModal, { path, selected });
}
diff --git a/src/download.tsx b/src/download.tsx
index e6d2e5a0..cc16312b 100644
--- a/src/download.tsx
+++ b/src/download.tsx
@@ -17,25 +17,27 @@
* along with Cockpit; If not, see .
*/
-import cockpit from 'cockpit';
+import cockpit from "cockpit";
-import type { FolderFileInfo } from './app';
+import type { FolderFileInfo } from "./app";
export function downloadFile(currentPath: string, selected: FolderFileInfo) {
- const payload = JSON.stringify({
- payload: "fsread1",
- binary: "raw",
- path: `${currentPath}/${selected.name}`,
- superuser: "try",
- external: {
- "content-disposition": `attachment; filename="${selected.name}"`,
- "content-type": "application/octet-stream",
- }
- });
+ const payload = JSON.stringify({
+ payload: "fsread1",
+ binary: "raw",
+ path: `${currentPath}/${selected.name}`,
+ superuser: "try",
+ external: {
+ "content-disposition": `attachment; filename="${selected.name}"`,
+ "content-type": "application/octet-stream",
+ },
+ });
- const encodedPayload = new TextEncoder().encode(payload);
- const query = window.btoa(String.fromCharCode(...encodedPayload));
+ const encodedPayload = new TextEncoder().encode(payload);
+ const query = window.btoa(String.fromCharCode(...encodedPayload));
- const prefix = (new URL(cockpit.transport.uri("channel/" + cockpit.transport.csrf_token))).pathname;
- window.open(`${prefix}?${query}`);
+ const prefix = new URL(
+ cockpit.transport.uri("channel/" + cockpit.transport.csrf_token),
+ ).pathname;
+ window.open(`${prefix}?${query}`);
}
diff --git a/src/files-breadcrumbs.tsx b/src/files-breadcrumbs.tsx
index 327ee5ed..be805e6d 100644
--- a/src/files-breadcrumbs.tsx
+++ b/src/files-breadcrumbs.tsx
@@ -21,14 +21,30 @@ import React from "react";
import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
-import { Dropdown, DropdownItem, DropdownList } from "@patternfly/react-core/dist/esm/components/Dropdown";
+import {
+ Dropdown,
+ DropdownItem,
+ DropdownList,
+} from "@patternfly/react-core/dist/esm/components/Dropdown";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon";
-import { MenuToggle, MenuToggleElement } from "@patternfly/react-core/dist/esm/components/MenuToggle";
+import {
+ MenuToggle,
+ MenuToggleElement,
+} from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { PageBreadcrumb } from "@patternfly/react-core/dist/esm/components/Page";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
-import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip";
+import {
+ Tooltip,
+ TooltipPosition,
+} from "@patternfly/react-core/dist/esm/components/Tooltip";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
-import { CheckIcon, HddIcon, PencilAltIcon, StarIcon, TimesIcon } from "@patternfly/react-icons";
+import {
+ CheckIcon,
+ HddIcon,
+ PencilAltIcon,
+ StarIcon,
+ TimesIcon,
+} from "@patternfly/react-icons";
import { useInit } from "hooks.js";
import cockpit from "cockpit";
@@ -40,302 +56,363 @@ import { basename } from "./common";
const _ = cockpit.gettext;
function useHostname() {
- const [hostname, setHostname] = React.useState(null);
-
- React.useEffect(() => {
- const client = cockpit.dbus('org.freedesktop.hostname1');
- const hostname1 = client.proxy('org.freedesktop.hostname1', '/org/freedesktop/hostname1');
-
- function changed() {
- if (hostname1.valid && typeof hostname1.Hostname === 'string') {
- setHostname(hostname1.Hostname);
- }
- }
-
- hostname1.addEventListener("changed", changed);
- return () => {
- hostname1.removeEventListener("changed", changed);
- client.close();
- };
- }, []);
-
- return hostname;
+ const [hostname, setHostname] = React.useState(null);
+
+ React.useEffect(() => {
+ const client = cockpit.dbus("org.freedesktop.hostname1");
+ const hostname1 = client.proxy(
+ "org.freedesktop.hostname1",
+ "/org/freedesktop/hostname1",
+ );
+
+ function changed() {
+ if (hostname1.valid && typeof hostname1.Hostname === "string") {
+ setHostname(hostname1.Hostname);
+ }
+ }
+
+ hostname1.addEventListener("changed", changed);
+ return () => {
+ hostname1.removeEventListener("changed", changed);
+ client.close();
+ };
+ }, []);
+
+ return hostname;
}
function BookmarkButton({ path }: { path: string[] }) {
- const [isOpen, setIsOpen] = React.useState(false);
- const [user, setUser] = React.useState(null);
- const [bookmarks, setBookmarks] = React.useState([]);
- const [bookmarkHandle, setBookmarkHandle] = React.useState | null>(null);
-
- const { addAlert, cwdInfo } = useFilesContext();
-
- const currentPath = path.join("/") || "/";
- const defaultBookmarks = [];
- if (user?.home)
- defaultBookmarks.push({ name: _("Home"), loc: user?.home }); // TODO: add trash
-
- const parse_uri = (line: string) => {
- // Drop everything after the space, we don't show renames
- line = line.replace(/\s.*/, '');
-
- // Drop the file:/// prefix
- line = line.replace('file://', '');
-
- // Nautilus decodes urls as paths can contain spaces
- return line.split('/').map(part => decodeURIComponent(part))
- .join('/');
- };
-
- useInit(async () => {
- const user_info = await cockpit.user();
- setUser(user_info);
-
- const handle = cockpit.file(`${user_info.home}/.config/gtk-3.0/bookmarks`);
- setBookmarkHandle(handle);
-
- handle.watch((content) => {
- if (content !== null) {
- setBookmarks(content.trim().split("\n")
- .filter(line => line.startsWith("file://"))
- .map(parse_uri));
- } else {
- setBookmarks([]);
- }
- });
-
- return [handle];
- });
-
- const saveBookmark = async () => {
- cockpit.assert(user !== null, "user is null while saving bookmarks");
- cockpit.assert(bookmarkHandle !== null, "bookmarkHandle is null while saving bookmarks");
- const bookmark_file = basename(bookmarkHandle.path);
- const config_dir = bookmarkHandle.path.replace(bookmark_file, "");
-
- try {
- await cockpit.spawn(["mkdir", "-p", config_dir]);
- } catch (err) {
- const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
- addAlert(_("Unable to create bookmark directory"), AlertVariant.danger, "bookmark-error",
- exc.message);
- return;
- }
-
- try {
- await bookmarkHandle.modify((old_content: string) => {
- if (bookmarks.includes(currentPath)) {
- return old_content.split('\n').filter(line => parse_uri(line) !== currentPath)
- .join('\n');
- } else {
- const newBoomark = "file://" + path.map(part => encodeURIComponent(part))
- .join('/') + "\n";
- return (old_content || '') + newBoomark;
- }
- });
- } catch (err) {
- const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
- addAlert(_("Unable to save bookmark file"), AlertVariant.danger, "bookmark-error",
- exc.message);
- }
- };
-
- const handleSelect = (_event: React.MouseEvent | undefined,
- value: string| number | undefined) => {
- if (value === "bookmark-action") {
- saveBookmark();
- } else {
- cockpit.location.go("/", { path: encodeURIComponent((value as string)) });
- }
- setIsOpen(false);
- };
-
- if (user === null)
- return null;
-
- let actionText = null;
- if (currentPath !== user.home) {
- if (bookmarks.includes(currentPath)) {
- actionText = _("Remove current directory");
- } else if (cwdInfo !== null) {
- actionText = _("Add bookmark");
- }
- }
-
- return (
- setIsOpen(isOpen)}
- onSelect={handleSelect}
- toggle={(toggleRef: React.Ref) => (
-
- }
- ref={toggleRef}
- onClick={() => setIsOpen(!isOpen)}
- isExpanded={isOpen}
- />
-
- )}
- >
-
- {defaultBookmarks.map(defaultBookmark =>
-
- {defaultBookmark.name}
- )}
- {bookmarks.length !== 0 &&
- }
- {bookmarks.map((bookmark: string) => (
-
- {bookmark}
- ))}
- {actionText !== null &&
- <>
-
-
- {actionText}
-
- >}
-
-
- );
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [user, setUser] = React.useState(null);
+ const [bookmarks, setBookmarks] = React.useState([]);
+ const [bookmarkHandle, setBookmarkHandle] =
+ React.useState | null>(null);
+
+ const { addAlert, cwdInfo } = useFilesContext();
+
+ const currentPath = path.join("/") || "/";
+ const defaultBookmarks = [];
+ if (user?.home) defaultBookmarks.push({ name: _("Home"), loc: user?.home }); // TODO: add trash
+
+ const parse_uri = (line: string) => {
+ // Drop everything after the space, we don't show renames
+ line = line.replace(/\s.*/, "");
+
+ // Drop the file:/// prefix
+ line = line.replace("file://", "");
+
+ // Nautilus decodes urls as paths can contain spaces
+ return line
+ .split("/")
+ .map((part) => decodeURIComponent(part))
+ .join("/");
+ };
+
+ useInit(async () => {
+ const user_info = await cockpit.user();
+ setUser(user_info);
+
+ const handle = cockpit.file(`${user_info.home}/.config/gtk-3.0/bookmarks`);
+ setBookmarkHandle(handle);
+
+ handle.watch((content) => {
+ if (content !== null) {
+ setBookmarks(
+ content
+ .trim()
+ .split("\n")
+ .filter((line) => line.startsWith("file://"))
+ .map(parse_uri),
+ );
+ } else {
+ setBookmarks([]);
+ }
+ });
+
+ return [handle];
+ });
+
+ const saveBookmark = async () => {
+ cockpit.assert(user !== null, "user is null while saving bookmarks");
+ cockpit.assert(
+ bookmarkHandle !== null,
+ "bookmarkHandle is null while saving bookmarks",
+ );
+ const bookmark_file = basename(bookmarkHandle.path);
+ const config_dir = bookmarkHandle.path.replace(bookmark_file, "");
+
+ try {
+ await cockpit.spawn(["mkdir", "-p", config_dir]);
+ } catch (err) {
+ const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
+ addAlert(
+ _("Unable to create bookmark directory"),
+ AlertVariant.danger,
+ "bookmark-error",
+ exc.message,
+ );
+ return;
+ }
+
+ try {
+ await bookmarkHandle.modify((old_content: string) => {
+ if (bookmarks.includes(currentPath)) {
+ return old_content
+ .split("\n")
+ .filter((line) => parse_uri(line) !== currentPath)
+ .join("\n");
+ } else {
+ const newBoomark =
+ "file://" +
+ path.map((part) => encodeURIComponent(part)).join("/") +
+ "\n";
+ return (old_content || "") + newBoomark;
+ }
+ });
+ } catch (err) {
+ const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
+ addAlert(
+ _("Unable to save bookmark file"),
+ AlertVariant.danger,
+ "bookmark-error",
+ exc.message,
+ );
+ }
+ };
+
+ const handleSelect = (
+ _event: React.MouseEvent | undefined,
+ value: string | number | undefined,
+ ) => {
+ if (value === "bookmark-action") {
+ saveBookmark();
+ } else {
+ cockpit.location.go("/", { path: encodeURIComponent(value as string) });
+ }
+ setIsOpen(false);
+ };
+
+ if (user === null) return null;
+
+ let actionText = null;
+ if (currentPath !== user.home) {
+ if (bookmarks.includes(currentPath)) {
+ actionText = _("Remove current directory");
+ } else if (cwdInfo !== null) {
+ actionText = _("Add bookmark");
+ }
+ }
+
+ return (
+ setIsOpen(isOpen)}
+ onSelect={handleSelect}
+ toggle={(toggleRef: React.Ref) => (
+
+ }
+ ref={toggleRef}
+ onClick={() => setIsOpen(!isOpen)}
+ isExpanded={isOpen}
+ />
+
+ )}
+ >
+
+ {defaultBookmarks.map((defaultBookmark) => (
+
+ {defaultBookmark.name}
+
+ ))}
+ {bookmarks.length !== 0 && }
+ {bookmarks.map((bookmark: string) => (
+
+ {bookmark}
+
+ ))}
+ {actionText !== null && (
+ <>
+
+
+ {actionText}
+
+ >
+ )}
+
+
+ );
}
// eslint-disable-next-line max-len
-export function FilesBreadcrumbs({ path, showHidden, setShowHidden }: { path: string[], showHidden: boolean, setShowHidden: React.Dispatch>}) {
- const [editMode, setEditMode] = React.useState(false);
- const [newPath, setNewPath] = React.useState(null);
- const hostname = useHostname();
-
- function navigate(n_parts: number) {
- cockpit.location.go("/", { path: encodeURIComponent(path.slice(0, n_parts).join("/")) });
- }
-
- const handleInputKey = (event: React.KeyboardEvent) => {
- // Don't propogate navigation specific events
- if (event.key === "ArrowDown" || event.key === "ArrowUp" ||
- event.key === "ArrowLeft" || event.key === "ArrowRight" ||
- event.key === "Delete") {
- event.stopPropagation();
- }
- if (event.key === "Enter") {
- event.stopPropagation();
- changePath();
- } else if (event.key === "Escape") {
- cancelPathEdit();
- }
- };
-
- const enableEditMode = () => {
- setEditMode(true);
- setNewPath(path.join("/") || "/");
- };
-
- const changePath = () => {
- setEditMode(false);
- cockpit.assert(newPath !== null, "newPath cannot be null");
- // HACK: strip trailing / to circumvent the path being `//` in breadcrumbs
- cockpit.location.go("/", { path: encodeURIComponent(newPath.replace(/\/$/, '')) });
- setNewPath(null);
- };
-
- const cancelPathEdit = () => {
- setNewPath(null);
- setEditMode(false);
- };
-
- const onToggleHidden = () => {
- setShowHidden(prevShowHidden => {
- localStorage.setItem("files:showHiddenFiles", !showHidden ? "true" : "false");
- return !prevShowHidden;
- });
- };
-
- const fullPath = path.slice(1);
- fullPath.unshift(hostname || "server");
-
- return (
-
-
-
- {!editMode &&
-
- }
- onClick={() => enableEditMode()}
- className="breadcrumb-button-edit"
- />
- }
- {!editMode && fullPath.map((dir, i) => {
- return (
-
- : null}
- variant="link" onClick={() => { navigate(i + 1) }}
- className={`breadcrumb-button breadcrumb-${i}`}
- >
- {dir || "/"}
-
- {dir !== "" && /
}
-
- );
- })}
- {editMode && newPath !== null &&
-
- event.target.select()}
- onKeyDown={handleInputKey}
- onChange={(_event, value) => setNewPath(value)}
- />
- }
-
- {editMode &&
- <>
- }
- onClick={changePath}
- className="breadcrumb-button-edit-apply"
- />
- }
- onClick={() => cancelPathEdit()}
- className="breadcrumb-button-edit-cancel"
- />
- >}
-
-
- {_("Show hidden items")}
-
- {showHidden &&
-
-
- }
-
-
-
- ]}
- />
-
-
-
- );
+export function FilesBreadcrumbs({
+ path,
+ showHidden,
+ setShowHidden,
+}: {
+ path: string[];
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
+}) {
+ const [editMode, setEditMode] = React.useState(false);
+ const [newPath, setNewPath] = React.useState(null);
+ const hostname = useHostname();
+
+ function navigate(n_parts: number) {
+ cockpit.location.go("/", {
+ path: encodeURIComponent(path.slice(0, n_parts).join("/")),
+ });
+ }
+
+ const handleInputKey = (event: React.KeyboardEvent) => {
+ // Don't propogate navigation specific events
+ if (
+ event.key === "ArrowDown" ||
+ event.key === "ArrowUp" ||
+ event.key === "ArrowLeft" ||
+ event.key === "ArrowRight" ||
+ event.key === "Delete"
+ ) {
+ event.stopPropagation();
+ }
+ if (event.key === "Enter") {
+ event.stopPropagation();
+ changePath();
+ } else if (event.key === "Escape") {
+ cancelPathEdit();
+ }
+ };
+
+ const enableEditMode = () => {
+ setEditMode(true);
+ setNewPath(path.join("/") || "/");
+ };
+
+ const changePath = () => {
+ setEditMode(false);
+ cockpit.assert(newPath !== null, "newPath cannot be null");
+ // HACK: strip trailing / to circumvent the path being `//` in breadcrumbs
+ cockpit.location.go("/", {
+ path: encodeURIComponent(newPath.replace(/\/$/, "")),
+ });
+ setNewPath(null);
+ };
+
+ const cancelPathEdit = () => {
+ setNewPath(null);
+ setEditMode(false);
+ };
+
+ const onToggleHidden = () => {
+ setShowHidden((prevShowHidden) => {
+ localStorage.setItem(
+ "files:showHiddenFiles",
+ !showHidden ? "true" : "false",
+ );
+ return !prevShowHidden;
+ });
+ };
+
+ const fullPath = path.slice(1);
+ fullPath.unshift(hostname || "server");
+
+ return (
+
+
+
+ {!editMode && (
+
+ }
+ onClick={() => enableEditMode()}
+ className="breadcrumb-button-edit"
+ />
+
+ )}
+ {!editMode &&
+ fullPath.map((dir, i) => {
+ return (
+
+ : null}
+ variant="link"
+ onClick={() => {
+ navigate(i + 1);
+ }}
+ className={`breadcrumb-button breadcrumb-${i}`}
+ >
+ {dir || "/"}
+
+ {dir !== "" && (
+
+ /
+
+ )}
+
+ );
+ })}
+ {editMode && newPath !== null && (
+
+ event.target.select()}
+ onKeyDown={handleInputKey}
+ onChange={(_event, value) => setNewPath(value)}
+ />
+
+ )}
+
+ {editMode && (
+ <>
+ }
+ onClick={changePath}
+ className="breadcrumb-button-edit-apply"
+ />
+ }
+ onClick={() => cancelPathEdit()}
+ className="breadcrumb-button-edit-cancel"
+ />
+ >
+ )}
+
+
+ {_("Show hidden items")}
+
+ {showHidden && (
+
+
+
+ )}
+
+
+ ,
+ ]}
+ />
+
+
+
+ );
}
diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx
index c69470ae..b0a747e3 100644
--- a/src/files-card-body.tsx
+++ b/src/files-card-body.tsx
@@ -17,14 +17,31 @@
* along with Cockpit; If not, see .
*/
-import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useRef,
+} from "react";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
-import { MenuItem, MenuList } from "@patternfly/react-core/dist/esm/components/Menu";
+import {
+ MenuItem,
+ MenuList,
+} from "@patternfly/react-core/dist/esm/components/Menu";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
-import { FolderIcon, SearchIcon } from '@patternfly/react-icons';
-import { SortByDirection, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table';
+import { FolderIcon, SearchIcon } from "@patternfly/react-icons";
+import {
+ SortByDirection,
+ Table,
+ Thead,
+ Tr,
+ Th,
+ Tbody,
+ Td,
+} from "@patternfly/react-table";
import cockpit from "cockpit";
import { ContextMenu } from "cockpit-components-context-menu";
@@ -41,435 +58,473 @@ import "./files-card-body.scss";
const _ = cockpit.gettext;
-function compare(sortBy: Sort): (a: FolderFileInfo, b: FolderFileInfo) => number {
- const dir_sort = (a: FolderFileInfo, b: FolderFileInfo) => Number(b.to === "dir") - Number(a.to === "dir");
- const name_sort = (a: FolderFileInfo, b: FolderFileInfo) => a.name.localeCompare(b.name);
-
- // treat non-regular files and infos with missing 'size' field as having size of zero
- const size = (a: FolderFileInfo) => (a.type === "reg" && a.size) || 0;
- const mtime = (a: FolderFileInfo) => a.mtime || 0; // fallbak for missing .mtime field
-
- switch (sortBy) {
- case Sort.az:
- return (a, b) => dir_sort(a, b) || name_sort(a, b);
- case Sort.za:
- return (a, b) => dir_sort(a, b) || name_sort(b, a);
- case Sort.first_modified:
- return (a, b) => dir_sort(a, b) || (mtime(a) - mtime(b)) || name_sort(a, b);
- case Sort.last_modified:
- return (a, b) => dir_sort(a, b) || (mtime(b) - mtime(a)) || name_sort(a, b);
- case Sort.largest_size:
- return (a, b) => dir_sort(a, b) || (size(b) - size(a)) || name_sort(a, b);
- case Sort.smallest_size:
- return (a, b) => dir_sort(a, b) || (size(a) - size(b)) || name_sort(a, b);
- }
+function compare(
+ sortBy: Sort,
+): (a: FolderFileInfo, b: FolderFileInfo) => number {
+ const dir_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
+ Number(b.to === "dir") - Number(a.to === "dir");
+ const name_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
+ a.name.localeCompare(b.name);
+
+ // treat non-regular files and infos with missing 'size' field as having size of zero
+ const size = (a: FolderFileInfo) => (a.type === "reg" && a.size) || 0;
+ const mtime = (a: FolderFileInfo) => a.mtime || 0; // fallbak for missing .mtime field
+
+ switch (sortBy) {
+ case Sort.az:
+ return (a, b) => dir_sort(a, b) || name_sort(a, b);
+ case Sort.za:
+ return (a, b) => dir_sort(a, b) || name_sort(b, a);
+ case Sort.first_modified:
+ return (a, b) => dir_sort(a, b) || mtime(a) - mtime(b) || name_sort(a, b);
+ case Sort.last_modified:
+ return (a, b) => dir_sort(a, b) || mtime(b) - mtime(a) || name_sort(a, b);
+ case Sort.largest_size:
+ return (a, b) => dir_sort(a, b) || size(b) - size(a) || name_sort(a, b);
+ case Sort.smallest_size:
+ return (a, b) => dir_sort(a, b) || size(a) - size(b) || name_sort(a, b);
+ }
}
-const ContextMenuItems = ({ path, selected, setSelected, clipboard, setClipboard } : {
- path: string[],
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
- clipboard: string[], setClipboard: React.Dispatch>,
+const ContextMenuItems = ({
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+}: {
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const dialogs = useDialogs();
- const { addAlert, cwdInfo } = useFilesContext();
- const menuItems = get_menu_items(
- path, selected, setSelected, clipboard, setClipboard, cwdInfo, addAlert, dialogs
- );
-
- return (
-
- {menuItems.map((item, i) =>
- item.type !== 'divider'
- ? (
-
- {item.title}
-
- )
- : )}
-
- );
+ const dialogs = useDialogs();
+ const { addAlert, cwdInfo } = useFilesContext();
+ const menuItems = get_menu_items(
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ cwdInfo,
+ addAlert,
+ dialogs,
+ );
+
+ return (
+
+ {menuItems.map((item, i) =>
+ item.type !== "divider" ? (
+
+ {item.title}
+
+ ) : (
+
+ ),
+ )}
+
+ );
};
export const FilesCardBody = ({
- currentFilter,
- setCurrentFilter,
- files,
- isGrid,
- path,
- selected,
- setSelected,
- sortBy,
- setSortBy,
- loadingFiles,
- clipboard,
- setClipboard,
- showHidden,
- setShowHidden,
-} : {
- currentFilter: string,
- setCurrentFilter: React.Dispatch>,
- files: FolderFileInfo[],
- isGrid: boolean,
- path: string[],
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
- sortBy: Sort, setSortBy: React.Dispatch>,
- loadingFiles: boolean,
- clipboard: string[], setClipboard: React.Dispatch>,
- showHidden: boolean,
- setShowHidden: React.Dispatch>,
+ currentFilter,
+ setCurrentFilter,
+ files,
+ isGrid,
+ path,
+ selected,
+ setSelected,
+ sortBy,
+ setSortBy,
+ loadingFiles,
+ clipboard,
+ setClipboard,
+ showHidden,
+ setShowHidden,
+}: {
+ currentFilter: string;
+ setCurrentFilter: React.Dispatch>;
+ files: FolderFileInfo[];
+ isGrid: boolean;
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
+ loadingFiles: boolean;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
}) => {
- const [boxPerRow, setBoxPerRow] = useState(0);
- const dialogs = useDialogs();
-
- const sortedFiles = useMemo(() => {
- return files
- .filter(file => showHidden ? true : !file.name.startsWith("."))
- .filter(file => file.name.toLowerCase().includes(currentFilter.toLowerCase()))
- .sort(compare(sortBy));
- }, [files, showHidden, currentFilter, sortBy]);
- const hiddenFilesCount = useMemo(() => {
- return files.filter(file => file.name.startsWith(".")).length;
- }, [files]);
- const isMounted = useRef();
- const folderViewRef = React.useRef(null);
-
- function calculateBoxPerRow () {
- const boxes = document.querySelectorAll(".fileview tbody > tr") as NodeListOf;
- if (boxes.length > 1) {
- let i = 0;
- const total = boxes.length;
- const firstOffset = boxes[0].offsetTop;
- while (++i < total && boxes[i].offsetTop === firstOffset);
- setBoxPerRow(i);
- }
- }
-
- const onDoubleClickNavigate = useCallback((file: FolderFileInfo) => {
- const newPath = [...path, file.name].join("/");
- if (file.to === "dir") {
- cockpit.location.go("/", { path: encodeURIComponent(newPath) });
- }
- }, [path]);
-
- useEffect(() => {
- calculateBoxPerRow();
- window.onresize = calculateBoxPerRow;
- return () => {
- window.onresize = null;
- };
- });
-
- useEffect(() => {
- let folderViewElem = null;
-
- const resetSelected = (e: MouseEvent) => {
- if ((e.target instanceof HTMLElement)) {
- if (e.target.id === "folder-view" || e.target.id === "files-card-parent" ||
- (e.target.parentElement && e.target.parentElement.id === "folder-view")) {
- if (selected.length !== 0) {
- setSelected([]);
- }
- }
- }
- };
-
- const handleDoubleClick = (ev: MouseEvent) => {
- ev.preventDefault();
- const name = getFilenameForEvent(ev);
- const file = sortedFiles?.find(file => file.name === name);
- if (!file)
- return null;
- if (!file) {
- resetSelected(ev);
- return;
- }
-
- onDoubleClickNavigate(file);
- };
-
- const handleClick = (ev: MouseEvent) => {
- ev.preventDefault();
- const name = getFilenameForEvent(ev);
- const file = sortedFiles?.find(file => file.name === name);
- if (!file) {
- resetSelected(ev);
- return;
- }
-
- if (ev.detail > 1) {
- onDoubleClickNavigate(file);
- } else {
- if (!ev.ctrlKey) {
- setSelected([file]);
- } else {
- setSelected(s => {
- if (!s.find(f => f.name === file.name)) {
- return [...s, file];
- } else {
- return s.filter(f => f.name !== file.name);
- }
- });
- }
- }
- };
-
- const handleContextMenu = (event: MouseEvent) => {
- const name = getFilenameForEvent(event);
- if (name !== null && selected.length > 1) {
- return;
- }
-
- const sel = sortedFiles?.find(file => file.name === name);
- if (sel) {
- setSelected([sel]);
- } else {
- setSelected([]);
- }
- };
-
- const onKeyboardNav = (e: KeyboardEvent) => {
- if (e.key === "ArrowRight") {
- setSelected(_selected => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
- const newIdx = selectedIdx < sortedFiles.length - 1
- ? selectedIdx + 1
- : 0;
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowLeft") {
- setSelected(_selected => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
- const newIdx = selectedIdx > 0
- ? selectedIdx - 1
- : sortedFiles.length - 1;
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowUp") {
- setSelected(_selected => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
- const newIdx = Math.max(selectedIdx - boxPerRow, 0);
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowDown") {
- setSelected(_selected => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
- const newIdx = Math.min(selectedIdx + boxPerRow, sortedFiles.length - 1);
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "Enter" && selected.length === 1) {
- onDoubleClickNavigate(selected[0]);
- } else if (e.key === "Delete" && selected.length !== 0) {
- confirm_delete(dialogs, path.join("/") + "/", selected, setSelected);
- }
- };
-
- if (folderViewRef.current) {
- folderViewElem = folderViewRef.current;
- folderViewElem.addEventListener("click", handleClick);
- folderViewElem.addEventListener("dblclick", handleDoubleClick);
- folderViewElem.addEventListener("contextmenu", handleContextMenu);
- }
-
- if (!isMounted.current && !dialogs.isActive()) {
- isMounted.current = true;
- document.addEventListener("keydown", onKeyboardNav);
- }
- if (dialogs.isActive())
- document.removeEventListener("keydown", onKeyboardNav);
- return () => {
- isMounted.current = false;
- document.removeEventListener("keydown", onKeyboardNav);
- if (folderViewElem) {
- folderViewElem.removeEventListener("click", handleClick);
- folderViewElem.removeEventListener("dblclick", handleDoubleClick);
- folderViewElem.removeEventListener("contextmenu", handleContextMenu);
- }
- };
- }, [
- setSelected,
- sortedFiles,
- boxPerRow,
- selected,
- onDoubleClickNavigate,
- dialogs,
- path,
- ]);
-
- // Generic event handler to look up the corresponding `data-item` for a click event when
- // a user clicks in the folder view. We use three event listeners (click,
- // doubleclick and rightclick) instead of having three event listeners per
- // item in the folder view. Having a lot of event listeners hurts
- // performance, this does require us to walk up the DOM until we find the
- // required `data-item` but this is a fairly trivial at the benefit of the
- // performance gains.
- const getFilenameForEvent = (event: Event) => {
- let data_item = null;
- let elem = event.target as HTMLElement;
- // Limit iterating to ten parents
- for (let i = 0; i < 10; i++) {
- data_item = elem.getAttribute("data-item");
- if (data_item)
- break;
-
- if (elem.parentElement)
- elem = elem.parentElement;
- else
- break;
- }
-
- return data_item;
- };
-
- if (loadingFiles)
- return (
-
-
-
- );
-
- const files_parent_id = "files-card-parent";
- const contextMenu = (
-
-
-
- );
-
- const sortColumn = (columnIndex: number) => ({
- sortBy: {
- index: filterColumnMapping[sortBy][0],
- direction: filterColumnMapping[sortBy][1],
- },
- onSort: (_event: unknown, index: number, direction: SortByDirection) => {
- setSortBy(filterColumns[index][direction].itemId);
- },
- columnIndex,
- });
-
- return (
-
- {contextMenu}
- {sortedFiles.length === 0 && currentFilter &&
-
setCurrentFilter("")}
- actionVariant="link"
- />}
- {sortedFiles.length === 0 && !currentFilter &&
- setShowHidden(true)}
- actionVariant="link"
- />}
- {sortedFiles.length !== 0 &&
-
-
-
- {_("Name")}
- {_("Size")}
- {_("Modified")}
-
-
-
-
- {sortedFiles.map((file, rowIndex) =>
- s.name === file.name)}
- />)}
-
-
}
-
- );
+ const [boxPerRow, setBoxPerRow] = useState(0);
+ const dialogs = useDialogs();
+
+ const sortedFiles = useMemo(() => {
+ return files
+ .filter((file) => (showHidden ? true : !file.name.startsWith(".")))
+ .filter((file) =>
+ file.name.toLowerCase().includes(currentFilter.toLowerCase()),
+ )
+ .sort(compare(sortBy));
+ }, [files, showHidden, currentFilter, sortBy]);
+ const hiddenFilesCount = useMemo(() => {
+ return files.filter((file) => file.name.startsWith(".")).length;
+ }, [files]);
+ const isMounted = useRef();
+ const folderViewRef = React.useRef(null);
+
+ function calculateBoxPerRow() {
+ const boxes = document.querySelectorAll(
+ ".fileview tbody > tr",
+ ) as NodeListOf;
+ if (boxes.length > 1) {
+ let i = 0;
+ const total = boxes.length;
+ const firstOffset = boxes[0].offsetTop;
+ while (++i < total && boxes[i].offsetTop === firstOffset);
+ setBoxPerRow(i);
+ }
+ }
+
+ const onDoubleClickNavigate = useCallback(
+ (file: FolderFileInfo) => {
+ const newPath = [...path, file.name].join("/");
+ if (file.to === "dir") {
+ cockpit.location.go("/", { path: encodeURIComponent(newPath) });
+ }
+ },
+ [path],
+ );
+
+ useEffect(() => {
+ calculateBoxPerRow();
+ window.onresize = calculateBoxPerRow;
+ return () => {
+ window.onresize = null;
+ };
+ });
+
+ useEffect(() => {
+ let folderViewElem = null;
+
+ const resetSelected = (e: MouseEvent) => {
+ if (e.target instanceof HTMLElement) {
+ if (
+ e.target.id === "folder-view" ||
+ e.target.id === "files-card-parent" ||
+ (e.target.parentElement &&
+ e.target.parentElement.id === "folder-view")
+ ) {
+ if (selected.length !== 0) {
+ setSelected([]);
+ }
+ }
+ }
+ };
+
+ const handleDoubleClick = (ev: MouseEvent) => {
+ ev.preventDefault();
+ const name = getFilenameForEvent(ev);
+ const file = sortedFiles?.find((file) => file.name === name);
+ if (!file) return null;
+ if (!file) {
+ resetSelected(ev);
+ return;
+ }
+
+ onDoubleClickNavigate(file);
+ };
+
+ const handleClick = (ev: MouseEvent) => {
+ ev.preventDefault();
+ const name = getFilenameForEvent(ev);
+ const file = sortedFiles?.find((file) => file.name === name);
+ if (!file) {
+ resetSelected(ev);
+ return;
+ }
+
+ if (ev.detail > 1) {
+ onDoubleClickNavigate(file);
+ } else {
+ if (!ev.ctrlKey) {
+ setSelected([file]);
+ } else {
+ setSelected((s) => {
+ if (!s.find((f) => f.name === file.name)) {
+ return [...s, file];
+ } else {
+ return s.filter((f) => f.name !== file.name);
+ }
+ });
+ }
+ }
+ };
+
+ const handleContextMenu = (event: MouseEvent) => {
+ const name = getFilenameForEvent(event);
+ if (name !== null && selected.length > 1) {
+ return;
+ }
+
+ const sel = sortedFiles?.find((file) => file.name === name);
+ if (sel) {
+ setSelected([sel]);
+ } else {
+ setSelected([]);
+ }
+ };
+
+ const onKeyboardNav = (e: KeyboardEvent) => {
+ if (e.key === "ArrowRight") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx =
+ selectedIdx < sortedFiles.length - 1 ? selectedIdx + 1 : 0;
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowLeft") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx =
+ selectedIdx > 0 ? selectedIdx - 1 : sortedFiles.length - 1;
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowUp") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx = Math.max(selectedIdx - boxPerRow, 0);
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowDown") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx = Math.min(
+ selectedIdx + boxPerRow,
+ sortedFiles.length - 1,
+ );
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "Enter" && selected.length === 1) {
+ onDoubleClickNavigate(selected[0]);
+ } else if (e.key === "Delete" && selected.length !== 0) {
+ confirm_delete(dialogs, path.join("/") + "/", selected, setSelected);
+ }
+ };
+
+ if (folderViewRef.current) {
+ folderViewElem = folderViewRef.current;
+ folderViewElem.addEventListener("click", handleClick);
+ folderViewElem.addEventListener("dblclick", handleDoubleClick);
+ folderViewElem.addEventListener("contextmenu", handleContextMenu);
+ }
+
+ if (!isMounted.current && !dialogs.isActive()) {
+ isMounted.current = true;
+ document.addEventListener("keydown", onKeyboardNav);
+ }
+ if (dialogs.isActive())
+ document.removeEventListener("keydown", onKeyboardNav);
+ return () => {
+ isMounted.current = false;
+ document.removeEventListener("keydown", onKeyboardNav);
+ if (folderViewElem) {
+ folderViewElem.removeEventListener("click", handleClick);
+ folderViewElem.removeEventListener("dblclick", handleDoubleClick);
+ folderViewElem.removeEventListener("contextmenu", handleContextMenu);
+ }
+ };
+ }, [
+ setSelected,
+ sortedFiles,
+ boxPerRow,
+ selected,
+ onDoubleClickNavigate,
+ dialogs,
+ path,
+ ]);
+
+ // Generic event handler to look up the corresponding `data-item` for a click event when
+ // a user clicks in the folder view. We use three event listeners (click,
+ // doubleclick and rightclick) instead of having three event listeners per
+ // item in the folder view. Having a lot of event listeners hurts
+ // performance, this does require us to walk up the DOM until we find the
+ // required `data-item` but this is a fairly trivial at the benefit of the
+ // performance gains.
+ const getFilenameForEvent = (event: Event) => {
+ let data_item = null;
+ let elem = event.target as HTMLElement;
+ // Limit iterating to ten parents
+ for (let i = 0; i < 10; i++) {
+ data_item = elem.getAttribute("data-item");
+ if (data_item) break;
+
+ if (elem.parentElement) elem = elem.parentElement;
+ else break;
+ }
+
+ return data_item;
+ };
+
+ if (loadingFiles)
+ return (
+
+
+
+ );
+
+ const files_parent_id = "files-card-parent";
+ const contextMenu = (
+
+
+
+ );
+
+ const sortColumn = (columnIndex: number) => ({
+ sortBy: {
+ index: filterColumnMapping[sortBy][0],
+ direction: filterColumnMapping[sortBy][1],
+ },
+ onSort: (_event: unknown, index: number, direction: SortByDirection) => {
+ setSortBy(filterColumns[index][direction].itemId);
+ },
+ columnIndex,
+ });
+
+ return (
+
+ {contextMenu}
+ {sortedFiles.length === 0 && currentFilter && (
+
setCurrentFilter("")}
+ actionVariant="link"
+ />
+ )}
+ {sortedFiles.length === 0 && !currentFilter && (
+ setShowHidden(true)}
+ actionVariant="link"
+ />
+ )}
+ {sortedFiles.length !== 0 && (
+
+
+
+
+ {_("Name")}
+
+
+ {_("Size")}
+
+
+ {_("Modified")}
+
+
+
+
+ {sortedFiles.map((file, rowIndex) => (
+ s.name === file.name)}
+ />
+ ))}
+
+
+ )}
+
+ );
};
const getFileType = (file: FolderFileInfo) => {
- if (file.to === "dir") {
- return "folder";
- } else if (file.category?.class) {
- return file.category.class;
- } else {
- return "file";
- }
+ if (file.to === "dir") {
+ return "folder";
+ } else if (file.category?.class) {
+ return file.category.class;
+ } else {
+ return "file";
+ }
};
// Memoize the Item component as rendering thousands of them on each render of parent component is costly.
-const Row = React.memo(function Item({ file, isSelected } : {
- file: FolderFileInfo,
- isSelected: boolean
+const Row = React.memo(function Item({
+ file,
+ isSelected,
+}: {
+ file: FolderFileInfo;
+ isSelected: boolean;
}) {
- const fileType = getFileType(file);
- let className = fileType;
- if (isSelected)
- className += " row-selected";
- if (file.type === "lnk")
- className += " symlink";
-
- return (
-
-
- {file.name}
-
-
- {file.type === 'reg' && cockpit.format_bytes(file.size)}
-
-
- {file.mtime ? timeformat.dateTime(file.mtime * 1000) : null}
-
-
- );
+ const fileType = getFileType(file);
+ let className = fileType;
+ if (isSelected) className += " row-selected";
+ if (file.type === "lnk") className += " symlink";
+
+ return (
+
+
+ {file.name}
+
+
+ {file.type === "reg" && cockpit.format_bytes(file.size)}
+
+
+ {file.mtime ? timeformat.dateTime(file.mtime * 1000) : null}
+
+
+ );
});
diff --git a/src/files-folder-view.tsx b/src/files-folder-view.tsx
index 892d98be..479ec6e0 100644
--- a/src/files-folder-view.tsx
+++ b/src/files-folder-view.tsx
@@ -19,68 +19,77 @@
import React, { useEffect, useState } from "react";
-import { Card } from '@patternfly/react-core/dist/esm/components/Card';
+import { Card } from "@patternfly/react-core/dist/esm/components/Card";
import type { FolderFileInfo } from "./app";
import { FilesCardBody } from "./files-card-body";
import { as_sort, FilesCardHeader } from "./header";
export const FilesFolderView = ({
- path,
- files,
- loadingFiles,
- showHidden,
- selected,
- setSelected,
- clipboard,
- setClipboard,
- setShowHidden,
+ path,
+ files,
+ loadingFiles,
+ showHidden,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ setShowHidden,
}: {
- path: string[],
- files: FolderFileInfo[],
- loadingFiles: boolean,
- showHidden: boolean,
- setShowHidden: React.Dispatch>,
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
- clipboard: string[], setClipboard: React.Dispatch>,
+ path: string[];
+ files: FolderFileInfo[];
+ loadingFiles: boolean;
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const [currentFilter, setCurrentFilter] = useState("");
- const [isGrid, setIsGrid] = useState(localStorage.getItem("files:isGrid") !== "false");
- const [sortBy, setSortBy] = useState(as_sort(localStorage.getItem("files:sort")));
- const onFilterChange = (_event: React.FormEvent, value: string) => setCurrentFilter(value);
+ const [currentFilter, setCurrentFilter] = useState("");
+ const [isGrid, setIsGrid] = useState(
+ localStorage.getItem("files:isGrid") !== "false",
+ );
+ const [sortBy, setSortBy] = useState(
+ as_sort(localStorage.getItem("files:sort")),
+ );
+ const onFilterChange = (
+ _event: React.FormEvent,
+ value: string,
+ ) => setCurrentFilter(value);
- // Reset the search filter on path changes
- useEffect(() => {
- setCurrentFilter("");
- }, [path]);
+ // Reset the search filter on path changes
+ useEffect(() => {
+ setCurrentFilter("");
+ }, [path]);
- return (
-
-
-
-
- );
+ return (
+
+
+
+
+ );
};
diff --git a/src/filetype-data.d.ts b/src/filetype-data.d.ts
index 7d061865..0bc52b04 100644
--- a/src/filetype-data.d.ts
+++ b/src/filetype-data.d.ts
@@ -1,4 +1,4 @@
-import type { FileTypeData } from './filetype-lookup';
+import type { FileTypeData } from "./filetype-lookup";
/* This data comes dynamically as JSON from filetype-plugin.ts */
declare const filetype_data: FileTypeData;
diff --git a/src/filetype-lookup.ts b/src/filetype-lookup.ts
index 020f1ad3..2811f10e 100644
--- a/src/filetype-lookup.ts
+++ b/src/filetype-lookup.ts
@@ -1,38 +1,41 @@
export enum Category {
- FILE = 0,
+ FILE = 0,
- ARCHIVE,
- AUDIO,
- CODE,
- IMAGE,
- TEXT,
- VIDEO,
+ ARCHIVE,
+ AUDIO,
+ CODE,
+ IMAGE,
+ TEXT,
+ VIDEO,
}
export interface CategoryMetadata extends Record {
- name: string;
- class: string;
+ name: string;
+ class: string;
}
export interface FileTypeData {
- categories: Record;
- extensions: Record;
- max_extension_length: number;
+ categories: Record;
+ extensions: Record;
+ max_extension_length: number;
}
export function filetype_lookup(filetype_data: FileTypeData, name: string) {
- // Never find a dot at offset '0' (the one for a hidden file), or one that
- // would produce an extension that we know not to be in the database.
- let offset = Math.max(1, name.length - filetype_data.max_extension_length - 1);
+ // Never find a dot at offset '0' (the one for a hidden file), or one that
+ // would produce an extension that we know not to be in the database.
+ let offset = Math.max(
+ 1,
+ name.length - filetype_data.max_extension_length - 1,
+ );
- // Allow finding extensions like '.tar.gz' (prefer longest first)
- while ((offset = name.indexOf('.', offset)) > 0) {
- offset++;
- const ext = name.substring(offset);
- if (ext in filetype_data.extensions) {
- return filetype_data.categories[filetype_data.extensions[ext]];
- }
- }
+ // Allow finding extensions like '.tar.gz' (prefer longest first)
+ while ((offset = name.indexOf(".", offset)) > 0) {
+ offset++;
+ const ext = name.substring(offset);
+ if (ext in filetype_data.extensions) {
+ return filetype_data.categories[filetype_data.extensions[ext]];
+ }
+ }
- return filetype_data.categories[Category.FILE];
+ return filetype_data.categories[Category.FILE];
}
diff --git a/src/filetype-plugin.ts b/src/filetype-plugin.ts
index aa1606c5..3fafcc8c 100644
--- a/src/filetype-plugin.ts
+++ b/src/filetype-plugin.ts
@@ -24,91 +24,191 @@
* with the return value of the `create_filetype_data()` function`.
*/
-import { Plugin, PluginBuild } from 'esbuild';
-import language_map from 'language-map';
-import mime_db from 'mime-db/db.json';
+import { Plugin, PluginBuild } from "esbuild";
+import language_map from "language-map";
+import mime_db from "mime-db/db.json";
-import { Category, FileTypeData } from './filetype-lookup';
+import { Category, FileTypeData } from "./filetype-lookup";
-export function create_filetype_data() : FileTypeData {
- const categories = {
- [Category.FILE]: { name: "Unknown type", class: "file" },
- [Category.ARCHIVE]: { name: "Archive file", class: "archive-file" },
- [Category.AUDIO]: { name: "Audio file", class: "audio-file" },
- [Category.CODE]: { name: "Source code file", class: "code-file" },
- [Category.IMAGE]: { name: "Image file", class: "image-file" },
- [Category.TEXT]: { name: "Text file", class: "text-file" },
- [Category.VIDEO]: { name: "Video file", class: "video-file" },
- } as const;
+export function create_filetype_data(): FileTypeData {
+ const categories = {
+ [Category.FILE]: { name: "Unknown type", class: "file" },
+ [Category.ARCHIVE]: { name: "Archive file", class: "archive-file" },
+ [Category.AUDIO]: { name: "Audio file", class: "audio-file" },
+ [Category.CODE]: { name: "Source code file", class: "code-file" },
+ [Category.IMAGE]: { name: "Image file", class: "image-file" },
+ [Category.TEXT]: { name: "Text file", class: "text-file" },
+ [Category.VIDEO]: { name: "Video file", class: "video-file" },
+ } as const;
- const extensions: { [ext: string]: Category } = {};
- let max_extension_length = 0;
+ const extensions: { [ext: string]: Category } = {};
+ let max_extension_length = 0;
- function add_category_extensions(category: Category, exts: string[]) {
- for (let ext of exts) {
- if (ext[0] === '.') {
- ext = ext.substring(1);
- }
+ function add_category_extensions(category: Category, exts: string[]) {
+ for (let ext of exts) {
+ if (ext[0] === ".") {
+ ext = ext.substring(1);
+ }
- max_extension_length = Math.max(max_extension_length, ext.length);
- extensions[ext] = category;
- }
- }
+ max_extension_length = Math.max(max_extension_length, ext.length);
+ extensions[ext] = category;
+ }
+ }
- // Broad-stroke categorization based on 'mime-db' package
- for (const [mimetype, details] of Object.entries(mime_db)) {
- if ('extensions' in details) {
- if (mimetype.startsWith('image/')) {
- add_category_extensions(Category.IMAGE, details.extensions);
- } else if (mimetype.startsWith('audio/')) {
- add_category_extensions(Category.AUDIO, details.extensions);
- } else if (mimetype.startsWith('video/')) {
- add_category_extensions(Category.VIDEO, details.extensions);
- } else if (mimetype.startsWith('text/')) {
- add_category_extensions(Category.TEXT, details.extensions);
- }
- }
- }
+ // Broad-stroke categorization based on 'mime-db' package
+ for (const [mimetype, details] of Object.entries(mime_db)) {
+ if ("extensions" in details) {
+ if (mimetype.startsWith("image/")) {
+ add_category_extensions(Category.IMAGE, details.extensions);
+ } else if (mimetype.startsWith("audio/")) {
+ add_category_extensions(Category.AUDIO, details.extensions);
+ } else if (mimetype.startsWith("video/")) {
+ add_category_extensions(Category.VIDEO, details.extensions);
+ } else if (mimetype.startsWith("text/")) {
+ add_category_extensions(Category.TEXT, details.extensions);
+ }
+ }
+ }
- // Archives, scraped from https://en.wikipedia.org/wiki/List_of_archive_formats
- add_category_extensions(
- Category.ARCHIVE,
- [
- '7z', 'F', 'LBR', 'Z', 'a', 'aar', 'ace', 'afa', 'alz', 'apk', 'ar', 'arc', 'arc', 'arj', 'ark',
- 'b1', 'b6z', 'ba', 'bh', 'br', 'bz2', 'cab', 'car', 'cdx', 'cfs', 'cpio', 'cpt', 'dar', 'dd',
- 'dgc', 'ear', 'gca', 'genozip', 'genozip', 'gz', 'ha', 'hki', 'ice', 'iso', 'jar', 'kgb', 'lbr',
- 'lha', 'lz', 'lz4', 'lzh', 'lzma', 'lzo', 'lzx', 'mar', 'pak', 'paq6', 'paq7', 'paq8', 'partimg',
- 'pea', 'phar', 'pim', 'pit', 'qda', 'rar', 'rk', 'rz', 's7z', 'sbx', 'sda', 'sea', 'sen', 'sfark',
- 'sfx', 'shar', 'shk', 'sit', 'sitx', 'sqx', 'sz', 'tar', 'tar.Z', 'tar.bz2', 'tar.gz', 'tar.lz',
- 'tar.xz', 'tar.zst', 'tbz2', 'tgz', 'tlz', 'txz', 'uc', 'uc0', 'uc2', 'uca', 'ucn', 'ue2', 'uha',
- 'ur2', 'war', 'wim', 'xar', 'xp3', 'xz', 'yz1', 'z', 'zip', 'zipx', 'zoo', 'zpaq', 'zst', 'zz'
- ]
- );
+ // Archives, scraped from https://en.wikipedia.org/wiki/List_of_archive_formats
+ add_category_extensions(Category.ARCHIVE, [
+ "7z",
+ "F",
+ "LBR",
+ "Z",
+ "a",
+ "aar",
+ "ace",
+ "afa",
+ "alz",
+ "apk",
+ "ar",
+ "arc",
+ "arc",
+ "arj",
+ "ark",
+ "b1",
+ "b6z",
+ "ba",
+ "bh",
+ "br",
+ "bz2",
+ "cab",
+ "car",
+ "cdx",
+ "cfs",
+ "cpio",
+ "cpt",
+ "dar",
+ "dd",
+ "dgc",
+ "ear",
+ "gca",
+ "genozip",
+ "genozip",
+ "gz",
+ "ha",
+ "hki",
+ "ice",
+ "iso",
+ "jar",
+ "kgb",
+ "lbr",
+ "lha",
+ "lz",
+ "lz4",
+ "lzh",
+ "lzma",
+ "lzo",
+ "lzx",
+ "mar",
+ "pak",
+ "paq6",
+ "paq7",
+ "paq8",
+ "partimg",
+ "pea",
+ "phar",
+ "pim",
+ "pit",
+ "qda",
+ "rar",
+ "rk",
+ "rz",
+ "s7z",
+ "sbx",
+ "sda",
+ "sea",
+ "sen",
+ "sfark",
+ "sfx",
+ "shar",
+ "shk",
+ "sit",
+ "sitx",
+ "sqx",
+ "sz",
+ "tar",
+ "tar.Z",
+ "tar.bz2",
+ "tar.gz",
+ "tar.lz",
+ "tar.xz",
+ "tar.zst",
+ "tbz2",
+ "tgz",
+ "tlz",
+ "txz",
+ "uc",
+ "uc0",
+ "uc2",
+ "uca",
+ "ucn",
+ "ue2",
+ "uha",
+ "ur2",
+ "war",
+ "wim",
+ "xar",
+ "xp3",
+ "xz",
+ "yz1",
+ "z",
+ "zip",
+ "zipx",
+ "zoo",
+ "zpaq",
+ "zst",
+ "zz",
+ ]);
- // Special treatment for programming languages based on GitHub's database
- for (const lang of Object.values(language_map)) {
- if (lang.type === 'programming' && 'extensions' in lang) {
- add_category_extensions(Category.CODE, lang.extensions);
- }
- }
+ // Special treatment for programming languages based on GitHub's database
+ for (const lang of Object.values(language_map)) {
+ if (lang.type === "programming" && "extensions" in lang) {
+ add_category_extensions(Category.CODE, lang.extensions);
+ }
+ }
- return { categories, extensions, max_extension_length };
+ return { categories, extensions, max_extension_length };
}
export class FileTypePlugin implements Plugin {
- name = 'cockpit-files-filetype-plugin';
+ name = "cockpit-files-filetype-plugin";
- setup(build: PluginBuild) {
- build.onResolve({ filter: /^\.\/filetype-data$/ }, args => ({
- path: args.path,
- namespace: 'cockpit-files-filetype-plugin',
- }));
+ setup(build: PluginBuild) {
+ build.onResolve({ filter: /^\.\/filetype-data$/ }, (args) => ({
+ path: args.path,
+ namespace: "cockpit-files-filetype-plugin",
+ }));
- build.onLoad({ filter: /.*/, namespace: 'cockpit-files-filetype-plugin' }, () => ({
- contents: JSON.stringify(create_filetype_data()),
- loader: 'json',
- }));
- }
+ build.onLoad(
+ { filter: /.*/, namespace: "cockpit-files-filetype-plugin" },
+ () => ({
+ contents: JSON.stringify(create_filetype_data()),
+ loader: "json",
+ }),
+ );
+ }
}
export const filetype_plugin = new FileTypePlugin();
diff --git a/src/header.tsx b/src/header.tsx
index 86dbd932..52e93b59 100644
--- a/src/header.tsx
+++ b/src/header.tsx
@@ -19,15 +19,29 @@
import React, { useState } from "react";
-import { CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
+import {
+ CardHeader,
+ CardTitle,
+} from "@patternfly/react-core/dist/esm/components/Card";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
-import { MenuToggle, MenuToggleAction } from "@patternfly/react-core/dist/esm/components/MenuToggle";
+import {
+ MenuToggle,
+ MenuToggleAction,
+} from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput";
-import { Select, SelectList, SelectOption } from "@patternfly/react-core/dist/esm/components/Select";
-import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/esm/components/Text";
+import {
+ Select,
+ SelectList,
+ SelectOption,
+} from "@patternfly/react-core/dist/esm/components/Select";
+import {
+ Text,
+ TextContent,
+ TextVariants,
+} from "@patternfly/react-core/dist/esm/components/Text";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { GripVerticalIcon, ListIcon } from "@patternfly/react-icons";
-import { SortByDirection } from '@patternfly/react-table';
+import { SortByDirection } from "@patternfly/react-table";
import cockpit from "cockpit";
@@ -36,169 +50,197 @@ import { UploadButton } from "./upload-button";
const _ = cockpit.gettext;
export enum Sort {
- az = 'az',
- za = 'za',
- largest_size = 'largest_size',
- smallest_size = 'smallest_size',
- first_modified = 'first_modified',
- last_modified = 'last_modified',
+ az = "az",
+ za = "za",
+ largest_size = "largest_size",
+ smallest_size = "smallest_size",
+ first_modified = "first_modified",
+ last_modified = "last_modified",
}
export function is_sort(x: unknown): x is Sort {
- return typeof x === 'string' && x in Sort;
+ return typeof x === "string" && x in Sort;
}
export function as_sort(x: unknown): Sort {
- return is_sort(x) ? x : Sort.az;
+ return is_sort(x) ? x : Sort.az;
}
export const filterColumns = [
- {
- title: _("Name"),
- [SortByDirection.asc]: {
- itemId: Sort.az,
- label: _("A-Z"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.za,
- label: _("Z-A"),
- }
- },
- {
- title: _("Size"),
- [SortByDirection.asc]: {
- itemId: Sort.largest_size,
- label: _("Largest size"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.smallest_size,
- label: _("Smallest size"),
- }
- },
- {
- title: _("Modified"),
- [SortByDirection.asc]: {
- itemId: Sort.first_modified,
- label: _("First modified"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.last_modified,
- label: _("Last modified"),
- },
- },
+ {
+ title: _("Name"),
+ [SortByDirection.asc]: {
+ itemId: Sort.az,
+ label: _("A-Z"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.za,
+ label: _("Z-A"),
+ },
+ },
+ {
+ title: _("Size"),
+ [SortByDirection.asc]: {
+ itemId: Sort.largest_size,
+ label: _("Largest size"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.smallest_size,
+ label: _("Smallest size"),
+ },
+ },
+ {
+ title: _("Modified"),
+ [SortByDirection.asc]: {
+ itemId: Sort.first_modified,
+ label: _("First modified"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.last_modified,
+ label: _("Last modified"),
+ },
+ },
] as const;
// { itemId: [index, sortdirection] }
-export const filterColumnMapping = filterColumns.reduce((a, v, i) => ({
- ...a,
- [v[SortByDirection.asc].itemId]: [i, SortByDirection.asc],
- [v[SortByDirection.desc].itemId]: [i, SortByDirection.desc]
-}), {}) as Record;
+export const filterColumnMapping = filterColumns.reduce(
+ (a, v, i) => ({
+ ...a,
+ [v[SortByDirection.asc].itemId]: [i, SortByDirection.asc],
+ [v[SortByDirection.desc].itemId]: [i, SortByDirection.desc],
+ }),
+ {},
+) as Record;
export const FilesCardHeader = ({
- currentFilter,
- onFilterChange,
- isGrid,
- setIsGrid,
- sortBy,
- setSortBy,
- path
+ currentFilter,
+ onFilterChange,
+ isGrid,
+ setIsGrid,
+ sortBy,
+ setSortBy,
+ path,
}: {
- currentFilter: string,
- onFilterChange: (_event: React.FormEvent, value: string) => void,
- isGrid: boolean, setIsGrid: React.Dispatch>,
- sortBy: Sort, setSortBy: React.Dispatch>
- path: string[],
+ currentFilter: string;
+ onFilterChange: (
+ _event: React.FormEvent,
+ value: string,
+ ) => void;
+ isGrid: boolean;
+ setIsGrid: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
+ path: string[];
}) => {
- return (
-
-
-
- onFilterChange(event as React.FormEvent, "")}
- />
-
-
-
-
-
- );
+ return (
+
+
+
+
+ onFilterChange(event as React.FormEvent, "")
+ }
+ />
+
+
+
+
+
+ );
};
-const ViewSelector = ({ isGrid, setIsGrid, sortBy, setSortBy }:
- { isGrid: boolean, setIsGrid: React.Dispatch>,
- sortBy: Sort, setSortBy: React.Dispatch>}) => {
- const [isOpen, setIsOpen] = useState(false);
- const onToggleClick = (isOpen: boolean) => setIsOpen(!isOpen);
- const onSelect = (_ev?: React.MouseEvent, itemId?: string | number) => {
- const sort = as_sort(itemId);
- setIsOpen(false);
- setSortBy(sort);
- localStorage.setItem("files:sort", sort);
- };
+const ViewSelector = ({
+ isGrid,
+ setIsGrid,
+ sortBy,
+ setSortBy,
+}: {
+ isGrid: boolean;
+ setIsGrid: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const onToggleClick = (isOpen: boolean) => setIsOpen(!isOpen);
+ const onSelect = (_ev?: React.MouseEvent, itemId?: string | number) => {
+ const sort = as_sort(itemId);
+ setIsOpen(false);
+ setSortBy(sort);
+ localStorage.setItem("files:sort", sort);
+ };
- return (
-
- );
+ return (
+
+ );
};
diff --git a/src/index.tsx b/src/index.tsx
index 79f5f1b9..724b3cd2 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -36,6 +36,6 @@ import { Application } from "./app";
import "./app.scss";
document.addEventListener("DOMContentLoaded", () => {
- const root = createRoot(document.getElementById("app")!);
- root.render( );
+ const root = createRoot(document.getElementById("app")!);
+ root.render( );
});
diff --git a/src/manifest.json b/src/manifest.json
index 64bb519f..7573ff2e 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,16 +1,16 @@
{
- "requires": {
- "cockpit": "318"
- },
+ "requires": {
+ "cockpit": "318"
+ },
- "tools": {
- "index": {
- "label": "File browser",
- "keywords": [
- {
- "matches": ["files", "explorer", "filesystem"]
- }
- ]
- }
- }
+ "tools": {
+ "index": {
+ "label": "File browser",
+ "keywords": [
+ {
+ "matches": ["files", "explorer", "filesystem"]
+ }
+ ]
+ }
+ }
}
diff --git a/src/menu.tsx b/src/menu.tsx
index b21b83ce..1674ba8b 100644
--- a/src/menu.tsx
+++ b/src/menu.tsx
@@ -23,123 +23,146 @@ import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import cockpit from "cockpit";
import type { FileInfo } from "cockpit/fsinfo";
-import type { Dialogs } from 'dialogs';
+import type { Dialogs } from "dialogs";
import type { FolderFileInfo } from "./app";
import { basename } from "./common";
-import { confirm_delete } from './dialogs/delete';
-import { show_create_directory_dialog } from './dialogs/mkdir';
-import { edit_permissions } from './dialogs/permissions';
-import { show_rename_dialog } from './dialogs/rename';
-import { downloadFile } from './download';
+import { confirm_delete } from "./dialogs/delete";
+import { show_create_directory_dialog } from "./dialogs/mkdir";
+import { edit_permissions } from "./dialogs/permissions";
+import { show_rename_dialog } from "./dialogs/rename";
+import { downloadFile } from "./download";
const _ = cockpit.gettext;
-type MenuItem = { type: "divider" } | {
- type?: never,
- title: string,
- id: string,
- onClick: () => void;
- isDisabled?: boolean;
- className?: string;
-};
+type MenuItem =
+ | { type: "divider" }
+ | {
+ type?: never;
+ title: string;
+ id: string;
+ onClick: () => void;
+ isDisabled?: boolean;
+ className?: string;
+ };
export function get_menu_items(
- path: string[],
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
- clipboard: string[], setClipboard: React.Dispatch>,
- cwdInfo: FileInfo | null,
- addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void,
- dialogs: Dialogs,
+ path: string[],
+ selected: FolderFileInfo[],
+ setSelected: React.Dispatch>,
+ clipboard: string[],
+ setClipboard: React.Dispatch>,
+ cwdInfo: FileInfo | null,
+ addAlert: (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => void,
+ dialogs: Dialogs,
) {
- const currentPath = path.join("/") + "/";
- const menuItems: MenuItem[] = [];
+ const currentPath = path.join("/") + "/";
+ const menuItems: MenuItem[] = [];
- if (selected.length === 0) {
- menuItems.push(
- {
- id: "paste-item",
- title: _("Paste"),
- isDisabled: clipboard.length === 0,
- onClick: () => {
- const existingFiles = clipboard.filter(sourcePath => cwdInfo?.entries?.[basename(sourcePath)]);
- if (existingFiles.length > 0) {
- addAlert(_("Pasting failed"), AlertVariant.danger, "paste-error",
- cockpit.format(_("\"$0\" exists, not overwriting with paste."),
- existingFiles.map(basename).join(", ")));
- return;
- }
- cockpit.spawn([
- "cp",
- "-R",
- ...clipboard,
- currentPath
- ]).catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`));
- }
- },
- { type: "divider" },
- {
- id: "create-item",
- title: _("Create directory"),
- onClick: () => show_create_directory_dialog(dialogs, currentPath)
- },
- { type: "divider" },
- {
- id: "edit-permissions",
- title: _("Edit permissions"),
- onClick: () => edit_permissions(dialogs, null, path)
- }
- );
- } else if (selected.length === 1) {
- menuItems.push(
- {
- id: "copy-item",
- title: _("Copy"),
- onClick: () => setClipboard([currentPath + selected[0].name]),
- },
- { type: "divider" },
- {
- id: "edit-permissions",
- title: _("Edit permissions"),
- onClick: () => edit_permissions(dialogs, selected[0], path)
- },
- {
- id: "rename-item",
- title: _("Rename"),
- onClick: () => show_rename_dialog(dialogs, path, selected[0])
- },
- { type: "divider" },
- {
- id: "delete-item",
- title: _("Delete"),
- className: "pf-m-danger",
- onClick: () => confirm_delete(dialogs, currentPath, selected, setSelected)
- },
- );
- if (selected[0].type === "reg")
- menuItems.push(
- { type: "divider" },
- {
- id: "download-item",
- title: _("Download"),
- onClick: () => downloadFile(currentPath, selected[0])
- }
- );
- } else if (selected.length > 1) {
- menuItems.push(
- {
- id: "copy-item",
- title: _("Copy"),
- onClick: () => setClipboard(selected.map(s => path.join("/") + "/" + s.name)),
- },
- {
- id: "delete-item",
- title: _("Delete"),
- className: "pf-m-danger",
- onClick: () => confirm_delete(dialogs, currentPath, selected, setSelected)
- }
- );
- }
+ if (selected.length === 0) {
+ menuItems.push(
+ {
+ id: "paste-item",
+ title: _("Paste"),
+ isDisabled: clipboard.length === 0,
+ onClick: () => {
+ const existingFiles = clipboard.filter(
+ (sourcePath) => cwdInfo?.entries?.[basename(sourcePath)],
+ );
+ if (existingFiles.length > 0) {
+ addAlert(
+ _("Pasting failed"),
+ AlertVariant.danger,
+ "paste-error",
+ cockpit.format(
+ _('"$0" exists, not overwriting with paste.'),
+ existingFiles.map(basename).join(", "),
+ ),
+ );
+ return;
+ }
+ cockpit
+ .spawn(["cp", "-R", ...clipboard, currentPath])
+ .catch((err) =>
+ addAlert(
+ err.message,
+ AlertVariant.danger,
+ `${new Date().getTime()}`,
+ ),
+ );
+ },
+ },
+ { type: "divider" },
+ {
+ id: "create-item",
+ title: _("Create directory"),
+ onClick: () => show_create_directory_dialog(dialogs, currentPath),
+ },
+ { type: "divider" },
+ {
+ id: "edit-permissions",
+ title: _("Edit permissions"),
+ onClick: () => edit_permissions(dialogs, null, path),
+ },
+ );
+ } else if (selected.length === 1) {
+ menuItems.push(
+ {
+ id: "copy-item",
+ title: _("Copy"),
+ onClick: () => setClipboard([currentPath + selected[0].name]),
+ },
+ { type: "divider" },
+ {
+ id: "edit-permissions",
+ title: _("Edit permissions"),
+ onClick: () => edit_permissions(dialogs, selected[0], path),
+ },
+ {
+ id: "rename-item",
+ title: _("Rename"),
+ onClick: () => show_rename_dialog(dialogs, path, selected[0]),
+ },
+ { type: "divider" },
+ {
+ id: "delete-item",
+ title: _("Delete"),
+ className: "pf-m-danger",
+ onClick: () =>
+ confirm_delete(dialogs, currentPath, selected, setSelected),
+ },
+ );
+ if (selected[0].type === "reg")
+ menuItems.push(
+ { type: "divider" },
+ {
+ id: "download-item",
+ title: _("Download"),
+ onClick: () => downloadFile(currentPath, selected[0]),
+ },
+ );
+ } else if (selected.length > 1) {
+ menuItems.push(
+ {
+ id: "copy-item",
+ title: _("Copy"),
+ onClick: () =>
+ setClipboard(selected.map((s) => path.join("/") + "/" + s.name)),
+ },
+ {
+ id: "delete-item",
+ title: _("Delete"),
+ className: "pf-m-danger",
+ onClick: () =>
+ confirm_delete(dialogs, currentPath, selected, setSelected),
+ },
+ );
+ }
- return menuItems;
+ return menuItems;
}
diff --git a/src/ownership.tsx b/src/ownership.tsx
index 10a46950..9bbd9044 100644
--- a/src/ownership.tsx
+++ b/src/ownership.tsx
@@ -1,5 +1,5 @@
-import cockpit from 'cockpit';
-import type { FileInfo } from 'cockpit/fsinfo';
+import cockpit from "cockpit";
+import type { FileInfo } from "cockpit/fsinfo";
// Determine the potential ownerships that a new item created in a particular
// directory might have, in case we have admin access. If superuser mode is
@@ -7,37 +7,42 @@ import type { FileInfo } from 'cockpit/fsinfo';
// case this function should not be used). This is very much a heuristic, and
// might change in the future.
export function get_owner_candidates(user: cockpit.UserInfo, info: FileInfo) {
- // In case the parent directory is setgid, we always override the group we
- // create as, mirroring the usual POSIX behaviour. There are other cases
- // where the "BSD group semantics" come into play (like mount options) but
- // we don't currently support those. We might in the future, though...
- const setgid = (info.group !== undefined && (info.mode || 0) & 0o2000) ? `${info.group}` : null;
+ // In case the parent directory is setgid, we always override the group we
+ // create as, mirroring the usual POSIX behaviour. There are other cases
+ // where the "BSD group semantics" come into play (like mount options) but
+ // we don't currently support those. We might in the future, though...
+ const setgid =
+ info.group !== undefined && (info.mode || 0) & 0o2000
+ ? `${info.group}`
+ : null;
- // Set() is ordered: we insert options in the order of preference.
- const candidates = new Set();
+ // Set() is ordered: we insert options in the order of preference.
+ const candidates = new Set();
- // Most preferred option: create with the ownership of the parent
- // directory. Don't offer this if:
- // - the directory is a world-writable sticky (like /tmp)
- // - we don't know the ownership information of the parent
- if (!info.mode || (info.mode & 0o1222) !== 0o1222) {
- if (info.user !== undefined && info.group !== undefined) {
- candidates.add(`${info.user}:${info.group}`);
- }
- }
+ // Most preferred option: create with the ownership of the parent
+ // directory. Don't offer this if:
+ // - the directory is a world-writable sticky (like /tmp)
+ // - we don't know the ownership information of the parent
+ if (!info.mode || (info.mode & 0o1222) !== 0o1222) {
+ if (info.user !== undefined && info.group !== undefined) {
+ candidates.add(`${info.user}:${info.group}`);
+ }
+ }
- // If we're authenticated as the superuser, we can do root:root as well.
- candidates.add(`root:${setgid || 'root'}`);
+ // If we're authenticated as the superuser, we can do root:root as well.
+ candidates.add(`root:${setgid || "root"}`);
- // The last option is always available: create as the normal user. In case
- // of something inside of the user's home directory, this was probably the
- // first option as well...
- // The first group from `cockpit.user()` is guaranteed to be the users default group
- if (user.groups.length >= 1) {
- candidates.add(`${user.name || user.id}:${setgid || user.groups[0] || user.gid}`);
- } else {
- candidates.add(`${user.name || user.id}:${setgid || user.gid}`);
- }
+ // The last option is always available: create as the normal user. In case
+ // of something inside of the user's home directory, this was probably the
+ // first option as well...
+ // The first group from `cockpit.user()` is guaranteed to be the users default group
+ if (user.groups.length >= 1) {
+ candidates.add(
+ `${user.name || user.id}:${setgid || user.groups[0] || user.gid}`,
+ );
+ } else {
+ candidates.add(`${user.name || user.id}:${setgid || user.gid}`);
+ }
- return [...candidates];
+ return [...candidates];
}
diff --git a/src/sidebar.tsx b/src/sidebar.tsx
index b4839ad4..1ad2f66c 100644
--- a/src/sidebar.tsx
+++ b/src/sidebar.tsx
@@ -20,16 +20,25 @@
import React, { useState, useEffect } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
-import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
import {
- DescriptionList,
- DescriptionListDescription,
- DescriptionListGroup,
- DescriptionListTerm
+ Card,
+ CardBody,
+ CardHeader,
+ CardTitle,
+} from "@patternfly/react-core/dist/esm/components/Card";
+import {
+ DescriptionList,
+ DescriptionListDescription,
+ DescriptionListGroup,
+ DescriptionListTerm,
} from "@patternfly/react-core/dist/esm/components/DescriptionList";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
-import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/esm/components/Text";
+import {
+ Text,
+ TextContent,
+ TextVariants,
+} from "@patternfly/react-core/dist/esm/components/Text";
import cockpit from "cockpit";
import { KebabDropdown } from "cockpit-components-dropdown";
@@ -44,145 +53,185 @@ import { get_menu_items } from "./menu";
const _ = cockpit.gettext;
function getDescriptionListItems(selected: FolderFileInfo) {
- return ([
- {
- id: "description-list-last-modified",
- label: _("Last modified"),
- value: 'mtime' in selected ? timeformat.dateTime(selected.mtime * 1000) : _('unknown')
- },
- {
- id: "description-list-owner",
- label: _("Owner"),
- value: 'user' in selected ? selected.user : _('unknown')
- },
- {
- id: "description-list-group",
- label: _("Group"),
- value: 'group' in selected ? selected.group : _('unknown')
- },
- ...(selected.type === "reg"
- ? [
- {
- id: "description-list-size",
- label: _("Size"),
- value: cockpit.format_bytes(selected.size) || _('unknown'),
- },
- ]
- : []),
- ...('mode' in selected
- ? [
- {
- id: "description-list-owner-permissions",
- label: _("Owner permissions"),
- value: get_permissions(selected.mode >> 6),
- },
- {
- id: "description-list-group-permissions",
- label: _("Group permissions"),
- value: get_permissions(selected.mode >> 3),
- },
- {
- id: "description-list-other-permissions",
- label: _("Other permissions"),
- value: get_permissions(selected.mode >> 0),
- },
- ]
- : [])
- ]);
+ return [
+ {
+ id: "description-list-last-modified",
+ label: _("Last modified"),
+ value:
+ "mtime" in selected
+ ? timeformat.dateTime(selected.mtime * 1000)
+ : _("unknown"),
+ },
+ {
+ id: "description-list-owner",
+ label: _("Owner"),
+ value: "user" in selected ? selected.user : _("unknown"),
+ },
+ {
+ id: "description-list-group",
+ label: _("Group"),
+ value: "group" in selected ? selected.group : _("unknown"),
+ },
+ ...(selected.type === "reg"
+ ? [
+ {
+ id: "description-list-size",
+ label: _("Size"),
+ value: cockpit.format_bytes(selected.size) || _("unknown"),
+ },
+ ]
+ : []),
+ ...("mode" in selected
+ ? [
+ {
+ id: "description-list-owner-permissions",
+ label: _("Owner permissions"),
+ value: get_permissions(selected.mode >> 6),
+ },
+ {
+ id: "description-list-group-permissions",
+ label: _("Group permissions"),
+ value: get_permissions(selected.mode >> 3),
+ },
+ {
+ id: "description-list-other-permissions",
+ label: _("Other permissions"),
+ value: get_permissions(selected.mode >> 0),
+ },
+ ]
+ : []),
+ ];
}
-export const SidebarPanelDetails = ({ files, path, selected, setSelected, showHidden, clipboard, setClipboard } : {
- files: FolderFileInfo[],
- path: string[],
- selected: FolderFileInfo[], setSelected: React.Dispatch>,
- showHidden: boolean,
- clipboard: string[], setClipboard: React.Dispatch>
+export const SidebarPanelDetails = ({
+ files,
+ path,
+ selected,
+ setSelected,
+ showHidden,
+ clipboard,
+ setClipboard,
+}: {
+ files: FolderFileInfo[];
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ showHidden: boolean;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const [info, setInfo] = useState(null);
- const { addAlert, cwdInfo } = useFilesContext();
+ const [info, setInfo] = useState(null);
+ const { addAlert, cwdInfo } = useFilesContext();
- useEffect(() => {
- if (selected.length === 1) {
- const filePath = path.join("/") + "/" + selected[0]?.name;
+ useEffect(() => {
+ if (selected.length === 1) {
+ const filePath = path.join("/") + "/" + selected[0]?.name;
- cockpit.spawn(["file", "--brief", filePath], { superuser: "try", err: "message" })
- .then(res => setInfo(res?.trim()))
- .catch(error => console.warn(`Failed to run file --brief on ${filePath}: ${error.toString()}`));
- }
- }, [path, selected]);
+ cockpit
+ .spawn(["file", "--brief", filePath], {
+ superuser: "try",
+ err: "message",
+ })
+ .then((res) => setInfo(res?.trim()))
+ .catch((error) =>
+ console.warn(
+ `Failed to run file --brief on ${filePath}: ${error.toString()}`,
+ ),
+ );
+ }
+ }, [path, selected]);
- const dialogs = useDialogs();
- const directory_name = path[path.length - 1];
- const hidden_count = files.filter(file => file.name.startsWith(".")).length;
- let shown_items = cockpit.format(cockpit.ngettext("$0 item", "$0 items", files.length), files.length);
- if (!showHidden)
- shown_items += " " + cockpit.format(cockpit.ngettext("($0 hidden)", "($0 hidden)", hidden_count), hidden_count);
+ const dialogs = useDialogs();
+ const directory_name = path[path.length - 1];
+ const hidden_count = files.filter((file) => file.name.startsWith(".")).length;
+ let shown_items = cockpit.format(
+ cockpit.ngettext("$0 item", "$0 items", files.length),
+ files.length,
+ );
+ if (!showHidden)
+ shown_items +=
+ " " +
+ cockpit.format(
+ cockpit.ngettext("($0 hidden)", "($0 hidden)", hidden_count),
+ hidden_count,
+ );
- const menuItems = get_menu_items(
- path, selected, setSelected, clipboard, setClipboard, cwdInfo, addAlert, dialogs
- ).map((option, i) => {
- if (option.type === 'divider')
- return ;
- return (
-
- {option.title}
-
- );
- });
+ const menuItems = get_menu_items(
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ cwdInfo,
+ addAlert,
+ dialogs,
+ ).map((option, i) => {
+ if (option.type === "divider") return ;
+ return (
+
+ {option.title}
+
+ );
+ });
- return (
-
-
-
-
-
- {selected.length === 1 &&
-
-
- {
- edit_permissions(dialogs, selected[0], path);
- }}
- >
- {_("Edit permissions")}
-
- }
-
- );
+ return (
+
+
+
+
+
+ {selected.length === 1 && (
+
+
+ {
+ edit_permissions(dialogs, selected[0], path);
+ }}
+ >
+ {_("Edit permissions")}
+
+
+ )}
+
+ );
};
diff --git a/src/upload-button.tsx b/src/upload-button.tsx
index c6197190..ab736030 100644
--- a/src/upload-button.tsx
+++ b/src/upload-button.tsx
@@ -23,8 +23,14 @@ import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
-import { Modal, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal";
-import { Popover, PopoverPosition } from "@patternfly/react-core/dist/esm/components/Popover";
+import {
+ Modal,
+ ModalVariant,
+} from "@patternfly/react-core/dist/esm/components/Modal";
+import {
+ Popover,
+ PopoverPosition,
+} from "@patternfly/react-core/dist/esm/components/Popover";
import { Progress } from "@patternfly/react-core/dist/esm/components/Progress";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { TrashIcon } from "@patternfly/react-icons";
@@ -43,309 +49,347 @@ import "./upload-button.scss";
const _ = cockpit.gettext;
interface ConflictResult {
- replace?: true;
- skip?: true;
- applyToAll: boolean;
+ replace?: true;
+ skip?: true;
+ applyToAll: boolean;
}
const FileConflictDialog = ({
- path,
- file,
- uploadFile,
- isMultiUpload,
- dialogResult
+ path,
+ file,
+ uploadFile,
+ isMultiUpload,
+ dialogResult,
}: {
- path: string[];
- file: FileInfo,
- uploadFile: File,
- isMultiUpload: boolean,
- dialogResult: DialogResult
+ path: string[];
+ file: FileInfo;
+ uploadFile: File;
+ isMultiUpload: boolean;
+ dialogResult: DialogResult;
}) => {
- const [applyToAll, setApplyToAll] = React.useState(false);
-
- const handleReplace = () => {
- dialogResult.resolve({ replace: true, applyToAll });
- };
-
- const handleSkip = () => {
- dialogResult.resolve({ skip: true, applyToAll });
- };
-
- const handleCancel = () => {
- dialogResult.reject(new Error("cancelled"));
- };
-
- return (
- {uploadFile.name})}
- titleIconVariant="warning"
- variant={ModalVariant.medium}
- onClose={handleCancel}
- isOpen
- footer={
- <>
- {_("Replace")}
- {isMultiUpload &&
- {_("Keep original")} }
- {_("Cancel")}
- >
- }
- >
-
- {cockpit.format(
- _("A file with the same name already exists in \"$0\". Replacing it will overwrite its content."),
- path.join('/')
- )}
-
-
-
- {_("New file")}
- {cockpit.format_bytes(uploadFile.size)}
- {timeformat.dateTime(uploadFile.lastModified)}
-
-
- {_("Original file on server")}
- {cockpit.format_bytes(file.size)}
- {file.mtime &&
- {timeformat.dateTime(file.mtime * 1000)}
}
-
-
- {isMultiUpload &&
- setApplyToAll(!applyToAll)}
- />}
-
- );
+ const [applyToAll, setApplyToAll] = React.useState(false);
+
+ const handleReplace = () => {
+ dialogResult.resolve({ replace: true, applyToAll });
+ };
+
+ const handleSkip = () => {
+ dialogResult.resolve({ skip: true, applyToAll });
+ };
+
+ const handleCancel = () => {
+ dialogResult.reject(new Error("cancelled"));
+ };
+
+ return (
+ {uploadFile.name})}
+ titleIconVariant="warning"
+ variant={ModalVariant.medium}
+ onClose={handleCancel}
+ isOpen
+ footer={
+ <>
+
+ {_("Replace")}
+
+ {isMultiUpload && (
+
+ {_("Keep original")}
+
+ )}
+
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {cockpit.format(
+ _(
+ 'A file with the same name already exists in "$0". Replacing it will overwrite its content.',
+ ),
+ path.join("/"),
+ )}
+
+
+
+ {_("New file")}
+ {cockpit.format_bytes(uploadFile.size)}
+
+ {timeformat.dateTime(uploadFile.lastModified)}
+
+
+
+ {_("Original file on server")}
+ {cockpit.format_bytes(file.size)}
+ {file.mtime && (
+
+ {timeformat.dateTime(file.mtime * 1000)}
+
+ )}
+
+
+ {isMultiUpload && (
+ setApplyToAll(!applyToAll)}
+ />
+ )}
+
+ );
};
export const UploadButton = ({
- path,
-} : {
- path: string[],
+ path,
+}: {
+ path: string[];
}) => {
- const ref = useRef(null);
- const { addAlert, cwdInfo } = useFilesContext();
- const dialogs = useDialogs();
- const [showPopover, setPopover] = React.useState(false);
- const [uploadedFiles, setUploadedFiles] = useState<{[name: string]:
- {file: File, progress: number, cancel:() => void}}>({});
-
- const handleClick = () => {
- if (ref.current) {
- ref.current.click();
- }
- };
-
- // Show a confirmation before closing the tab while uploading
- const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
- event.preventDefault();
-
- // Included for legacy support, e.g. Chrome/Edge < 119
- event.returnValue = true;
- };
-
- const onUpload = async (event: React.ChangeEvent) => {
- cockpit.assert(event.target.files, "not an ?");
- cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
- let next_progress = 0;
- const toUploadFiles = [];
-
- const resetInput = () => {
- // Reset input field in the case a download was cancelled and has to be re-uploaded
- // https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
- event.target.value = "";
- };
-
- let resolution;
- let replaceAll = false;
- let skipAll = false;
- for (let i = 0; i < event.target.files.length; i++) {
- const uploadFile = event.target.files[i];
- const file = cwdInfo?.entries[uploadFile.name];
-
- if (replaceAll)
- toUploadFiles.push(uploadFile);
- else if (file && skipAll) {
- continue;
- } else if (file) {
- try {
- resolution = await dialogs.run(FileConflictDialog, {
- path, file, uploadFile, isMultiUpload: event.target.files.length > 1
- });
- } catch (exc) {
- resetInput();
- return;
- }
-
- if (resolution.skip) {
- if (resolution.applyToAll)
- skipAll = true;
- continue;
- }
-
- if (resolution.applyToAll && resolution.replace)
- replaceAll = true;
-
- toUploadFiles.push(uploadFile);
- } else {
- toUploadFiles.push(uploadFile);
- }
- }
-
- if (toUploadFiles.length === 0) {
- resetInput();
- return;
- }
-
- window.addEventListener("beforeunload", beforeUnloadHandler);
-
- const cancelledUploads = [];
- await Promise.allSettled(toUploadFiles.map(async (file: File) => {
- const tmp_path = path.slice();
- tmp_path.push(file.name);
- const destination = tmp_path.join('/');
- const abort = new AbortController();
-
- setUploadedFiles(oldFiles => {
- return {
- [file.name]: { file, progress: 0, cancel: () => abort.abort() },
- ...oldFiles,
- };
- });
-
- try {
- await upload(destination, file, (progress) => {
- const now = performance.now();
- if (now < next_progress)
- return;
- next_progress = now + 200; // only rerender every 200ms
- setUploadedFiles(oldFiles => {
- const oldFile = oldFiles[file.name];
- return {
- ...oldFiles,
- [file.name]: { ...oldFile, progress },
- };
- });
- }, abort.signal);
- // TODO: pass { superuser: try } depending on directory owner
- } catch (exc) {
- cockpit.assert(exc instanceof Error, "Unknown exception type");
- if (exc instanceof DOMException && exc.name === 'AbortError') {
- addAlert(_("Cancelled"), AlertVariant.warning, "upload",
- cockpit.format(_("Cancelled upload of $0"), file.name));
- } else {
- addAlert(_("Upload error"), AlertVariant.danger, "upload-error", exc.toString());
- }
- cancelledUploads.push(file);
- } finally {
- setUploadedFiles(oldFiles => {
- const copy = { ...oldFiles };
- delete copy[file.name];
- return copy;
- });
- }
- }));
-
- resetInput();
- window.removeEventListener("beforeunload", beforeUnloadHandler);
-
- // If all uploads are cancelled, don't show an alert
- if (cancelledUploads.length !== toUploadFiles.length) {
- addAlert(_("Upload complete"), AlertVariant.success, "upload-success", _("Successfully uploaded file(s)"));
- }
- };
-
- const isUploading = Object.keys(uploadedFiles).length !== 0;
- let popover;
-
- if (isUploading) {
- let totalSize = 0;
- let totalSent = 0;
-
- Object.keys(uploadedFiles).forEach((key) => {
- const uploadedFile = uploadedFiles[key];
- totalSize += uploadedFile.file.size;
- totalSent += uploadedFile.progress;
- });
-
- const overallProgress = ((totalSent / totalSize) * 100).toFixed(2);
-
- popover = (
- {_("Uploads")}
}
- bodyContent={Object.keys(uploadedFiles).map((key, index) => {
- const file = uploadedFiles[key];
- return (
-
-
-
-
- }
- className={`cancel-button-${index} cancel-button`}
- onClick={file.cancel}
- aria-label={cockpit.format(_("Cancel upload of $0"), file.file.name)}
- />
-
-
- );
- })}
- isVisible={showPopover}
- shouldClose={() => setPopover(false)}
- >
- setPopover(true)}
- className="progress-wrapper"
- variant="plain"
- icon={
-
- }
- />
-
- );
- }
-
- return (
- <>
- {popover}
-
- {_("Upload")}
-
-
- >
- );
+ const ref = useRef(null);
+ const { addAlert, cwdInfo } = useFilesContext();
+ const dialogs = useDialogs();
+ const [showPopover, setPopover] = React.useState(false);
+ const [uploadedFiles, setUploadedFiles] = useState<{
+ [name: string]: { file: File; progress: number; cancel: () => void };
+ }>({});
+
+ const handleClick = () => {
+ if (ref.current) {
+ ref.current.click();
+ }
+ };
+
+ // Show a confirmation before closing the tab while uploading
+ const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+
+ // Included for legacy support, e.g. Chrome/Edge < 119
+ event.returnValue = true;
+ };
+
+ const onUpload = async (event: React.ChangeEvent) => {
+ cockpit.assert(event.target.files, "not an ?");
+ cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
+ let next_progress = 0;
+ const toUploadFiles = [];
+
+ const resetInput = () => {
+ // Reset input field in the case a download was cancelled and has to be re-uploaded
+ // https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
+ event.target.value = "";
+ };
+
+ let resolution;
+ let replaceAll = false;
+ let skipAll = false;
+ for (let i = 0; i < event.target.files.length; i++) {
+ const uploadFile = event.target.files[i];
+ const file = cwdInfo?.entries[uploadFile.name];
+
+ if (replaceAll) toUploadFiles.push(uploadFile);
+ else if (file && skipAll) {
+ continue;
+ } else if (file) {
+ try {
+ resolution = await dialogs.run(FileConflictDialog, {
+ path,
+ file,
+ uploadFile,
+ isMultiUpload: event.target.files.length > 1,
+ });
+ } catch (exc) {
+ resetInput();
+ return;
+ }
+
+ if (resolution.skip) {
+ if (resolution.applyToAll) skipAll = true;
+ continue;
+ }
+
+ if (resolution.applyToAll && resolution.replace) replaceAll = true;
+
+ toUploadFiles.push(uploadFile);
+ } else {
+ toUploadFiles.push(uploadFile);
+ }
+ }
+
+ if (toUploadFiles.length === 0) {
+ resetInput();
+ return;
+ }
+
+ window.addEventListener("beforeunload", beforeUnloadHandler);
+
+ const cancelledUploads = [];
+ await Promise.allSettled(
+ toUploadFiles.map(async (file: File) => {
+ const tmp_path = path.slice();
+ tmp_path.push(file.name);
+ const destination = tmp_path.join("/");
+ const abort = new AbortController();
+
+ setUploadedFiles((oldFiles) => {
+ return {
+ [file.name]: { file, progress: 0, cancel: () => abort.abort() },
+ ...oldFiles,
+ };
+ });
+
+ try {
+ await upload(
+ destination,
+ file,
+ (progress) => {
+ const now = performance.now();
+ if (now < next_progress) return;
+ next_progress = now + 200; // only rerender every 200ms
+ setUploadedFiles((oldFiles) => {
+ const oldFile = oldFiles[file.name];
+ return {
+ ...oldFiles,
+ [file.name]: { ...oldFile, progress },
+ };
+ });
+ },
+ abort.signal,
+ );
+ // TODO: pass { superuser: try } depending on directory owner
+ } catch (exc) {
+ cockpit.assert(exc instanceof Error, "Unknown exception type");
+ if (exc instanceof DOMException && exc.name === "AbortError") {
+ addAlert(
+ _("Cancelled"),
+ AlertVariant.warning,
+ "upload",
+ cockpit.format(_("Cancelled upload of $0"), file.name),
+ );
+ } else {
+ addAlert(
+ _("Upload error"),
+ AlertVariant.danger,
+ "upload-error",
+ exc.toString(),
+ );
+ }
+ cancelledUploads.push(file);
+ } finally {
+ setUploadedFiles((oldFiles) => {
+ const copy = { ...oldFiles };
+ delete copy[file.name];
+ return copy;
+ });
+ }
+ }),
+ );
+
+ resetInput();
+ window.removeEventListener("beforeunload", beforeUnloadHandler);
+
+ // If all uploads are cancelled, don't show an alert
+ if (cancelledUploads.length !== toUploadFiles.length) {
+ addAlert(
+ _("Upload complete"),
+ AlertVariant.success,
+ "upload-success",
+ _("Successfully uploaded file(s)"),
+ );
+ }
+ };
+
+ const isUploading = Object.keys(uploadedFiles).length !== 0;
+ let popover;
+
+ if (isUploading) {
+ let totalSize = 0;
+ let totalSent = 0;
+
+ Object.keys(uploadedFiles).forEach((key) => {
+ const uploadedFile = uploadedFiles[key];
+ totalSize += uploadedFile.file.size;
+ totalSent += uploadedFile.progress;
+ });
+
+ const overallProgress = ((totalSent / totalSize) * 100).toFixed(2);
+
+ popover = (
+ {_("Uploads")}}
+ bodyContent={Object.keys(uploadedFiles).map((key, index) => {
+ const file = uploadedFiles[key];
+ return (
+
+
+
+
+ }
+ className={`cancel-button-${index} cancel-button`}
+ onClick={file.cancel}
+ aria-label={cockpit.format(
+ _("Cancel upload of $0"),
+ file.file.name,
+ )}
+ />
+
+
+ );
+ })}
+ isVisible={showPopover}
+ shouldClose={() => setPopover(false)}
+ >
+ setPopover(true)}
+ className="progress-wrapper"
+ variant="plain"
+ icon={
+
+ }
+ />
+
+ );
+ }
+
+ return (
+ <>
+ {popover}
+
+ {_("Upload")}
+
+
+ >
+ );
};
diff --git a/tsconfig.json b/tsconfig.json
index c4b1669d..8b4f3f89 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,22 +1,17 @@
{
- "compilerOptions": {
- "allowJs": true,
- "checkJs": true,
- "exactOptionalPropertyTypes": true,
- "jsx": "react",
- "lib": [
- "dom",
- "es2020"
- ],
- "paths": {
- "*": ["./pkg/lib/*"]
- },
- "moduleResolution": "bundler",
- "noEmit": true,
- "strict": true,
- "target": "es2020"
- },
- "include": [
- "src/**/*"
- ]
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "exactOptionalPropertyTypes": true,
+ "jsx": "react",
+ "lib": ["dom", "es2020"],
+ "paths": {
+ "*": ["./pkg/lib/*"]
+ },
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "strict": true,
+ "target": "es2020"
+ },
+ "include": ["src/**/*"]
}
From 30e03613727c866a57d57eabb59a41d86e70b8de Mon Sep 17 00:00:00 2001
From: Luna
Date: Wed, 24 Jul 2024 14:25:14 +0530
Subject: [PATCH 3/5] update config
---
biome.jsonc | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/biome.jsonc b/biome.jsonc
index 6e9bb857..7129bdf0 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
+ "$schema": "node_modules/@biomejs/biome/configuration_schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
@@ -123,14 +123,17 @@
"useGetterReturn": "error",
"useValidTypeof": "error"
}
- },
- "ignore": ["node_modules/*", "pkg/lib/*", "bots/*"]
+ }
},
"javascript": {
"globals": ["require", "module", "document", "navigator", "window"]
},
- "overrides": [{ "include": ["**/*.ts", "**/*.tsx"] }],
"formatter": {
+ "indentStyle":"space",
+ "indentWidth":4
+ },
+ "overrides": [{ "include": ["**/*.ts", "**/*.tsx"] }],
+ "files": {
"ignore": ["node_modules/*", "pkg/lib/*", "bots/*"]
}
}
From 268b3061d378fc7161e3df0ea7d7ee0166ba0a76 Mon Sep 17 00:00:00 2001
From: Luna
Date: Wed, 24 Jul 2024 14:25:26 +0530
Subject: [PATCH 4/5] run formatter
---
src/app.tsx | 374 +++++++-------
src/common.ts | 50 +-
src/dialogs/delete.tsx | 187 +++----
src/dialogs/mkdir.tsx | 271 +++++-----
src/dialogs/permissions.jsx | 422 ++++++++--------
src/dialogs/rename.tsx | 308 ++++++------
src/download.tsx | 32 +-
src/files-breadcrumbs.tsx | 745 ++++++++++++++--------------
src/files-card-body.tsx | 961 +++++++++++++++++++-----------------
src/files-folder-view.tsx | 124 ++---
src/filetype-lookup.ts | 54 +-
src/filetype-plugin.ts | 332 ++++++-------
src/header.tsx | 369 +++++++-------
src/index.tsx | 4 +-
src/manifest.json | 26 +-
src/menu.tsx | 252 +++++-----
src/ownership.tsx | 66 +--
src/sidebar.tsx | 376 +++++++-------
src/upload-button.tsx | 696 +++++++++++++-------------
19 files changed, 2908 insertions(+), 2741 deletions(-)
diff --git a/src/app.tsx b/src/app.tsx
index 4454857f..7c5ee355 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -20,20 +20,20 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import {
- AlertGroup,
- Alert,
- AlertVariant,
- AlertActionCloseButton,
+ AlertGroup,
+ Alert,
+ AlertVariant,
+ AlertActionCloseButton,
} from "@patternfly/react-core/dist/esm/components/Alert";
import { Card } from "@patternfly/react-core/dist/esm/components/Card";
import {
- Page,
- PageSection,
+ Page,
+ PageSection,
} from "@patternfly/react-core/dist/esm/components/Page";
import {
- Sidebar,
- SidebarPanel,
- SidebarContent,
+ Sidebar,
+ SidebarPanel,
+ SidebarContent,
} from "@patternfly/react-core/dist/esm/components/Sidebar";
import { ExclamationCircleIcon } from "@patternfly/react-icons";
@@ -53,192 +53,200 @@ import { SidebarPanelDetails } from "./sidebar";
superuser.reload_page_on_change();
interface Alert {
- key: string;
- title: string;
- variant: AlertVariant;
- detail?: string;
+ key: string;
+ title: string;
+ variant: AlertVariant;
+ detail?: string;
}
export interface FolderFileInfo extends FileInfo {
- name: string;
- to: string | null;
- category: { class: string } | null;
+ name: string;
+ to: string | null;
+ category: { class: string } | null;
}
interface FilesContextType {
- addAlert: (
- title: string,
- variant: AlertVariant,
- key: string,
- detail?: string,
- ) => void;
- cwdInfo: FileInfo | null;
+ addAlert: (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => void;
+ cwdInfo: FileInfo | null;
}
export const FilesContext = React.createContext({
- addAlert: () => console.warn("FilesContext not initialized"),
- cwdInfo: null,
+ addAlert: () => console.warn("FilesContext not initialized"),
+ cwdInfo: null,
} as FilesContextType);
export const useFilesContext = () => useContext(FilesContext);
export const Application = () => {
- const { options } = usePageLocation();
- const [loading, setLoading] = useState(true);
- const [loadingFiles, setLoadingFiles] = useState(true);
- const [errorMessage, setErrorMessage] = useState("");
- const [files, setFiles] = useState([]);
- const [selected, setSelected] = useState([]);
- const [showHidden, setShowHidden] = useState(
- localStorage.getItem("files:showHiddenFiles") === "true",
- );
- const [clipboard, setClipboard] = useState([]);
- const [alerts, setAlerts] = useState([]);
- const [cwdInfo, setCwdInfo] = useState(null);
-
- const currentPath = decodeURIComponent(options.path?.toString() || "");
- // the function itself is not expensive, but `path` is later used in expensive computation
- // and changing its reference value on every render causes performance issues
- const path = useMemo(() => currentPath?.split("/"), [currentPath]);
-
- useEffect(() => {
- cockpit.user().then((user) => {
- if (options.path === undefined) {
- cockpit.location.replace("/", { path: encodeURIComponent(user.home) });
- }
- });
- }, [options]);
-
- useEffect(() => {
- if (options.path === undefined) {
- return;
- }
-
- // Reset selected when path changes
- setSelected([]);
-
- const client = new FsInfoClient(
- `/${currentPath}`,
- [
- "type",
- "mode",
- "size",
- "mtime",
- "user",
- "group",
- "target",
- "entries",
- "targets",
- ],
- { superuser: "try" },
- );
-
- const disconnect = client.on("change", (state) => {
- setLoading(false);
- setLoadingFiles(!(state.info || state.error));
- setCwdInfo(state.info || null);
- setErrorMessage(state.error?.message ?? "");
- const entries = Object.entries(state?.info?.entries || {});
- const files = entries.map(([name, attrs]) => {
- const to = FsInfoClient.target(state.info!, name)?.type ?? null;
- const category =
- to === "reg" ? filetype_lookup(filetype_data, name) : null;
- return { ...attrs, name, to, category };
- });
- setFiles(files);
- });
-
- return () => {
- disconnect();
- client.close();
- };
- }, [options, currentPath]);
-
- if (loading) return ;
-
- const addAlert = (
- title: string,
- variant: AlertVariant,
- key: string,
- detail?: string,
- ) => {
- setAlerts((prevAlerts) => [
- ...prevAlerts,
- { title, variant, key, ...(detail && { detail }) },
- ]);
- };
- const removeAlert = (key: string) =>
- setAlerts((prevAlerts) => prevAlerts.filter((alert) => alert.key !== key));
-
- return (
-
-
-
-
- {alerts.map((alert) => (
- removeAlert(alert.key)}
- />
- }
- key={alert.key}
- >
- {alert.detail}
-
- ))}
-
-
-
-
-
- {errorMessage && (
-
-
-
- )}
- {!errorMessage && (
-
- )}
-
-
- files.find((f) => f.name === s.name))
- .filter((s) => s !== undefined)}
- showHidden={showHidden}
- setSelected={setSelected}
- clipboard={clipboard}
- setClipboard={setClipboard}
- files={files}
- />
-
-
-
-
-
-
- );
+ const { options } = usePageLocation();
+ const [loading, setLoading] = useState(true);
+ const [loadingFiles, setLoadingFiles] = useState(true);
+ const [errorMessage, setErrorMessage] = useState("");
+ const [files, setFiles] = useState([]);
+ const [selected, setSelected] = useState([]);
+ const [showHidden, setShowHidden] = useState(
+ localStorage.getItem("files:showHiddenFiles") === "true",
+ );
+ const [clipboard, setClipboard] = useState([]);
+ const [alerts, setAlerts] = useState([]);
+ const [cwdInfo, setCwdInfo] = useState(null);
+
+ const currentPath = decodeURIComponent(options.path?.toString() || "");
+ // the function itself is not expensive, but `path` is later used in expensive computation
+ // and changing its reference value on every render causes performance issues
+ const path = useMemo(() => currentPath?.split("/"), [currentPath]);
+
+ useEffect(() => {
+ cockpit.user().then((user) => {
+ if (options.path === undefined) {
+ cockpit.location.replace("/", {
+ path: encodeURIComponent(user.home),
+ });
+ }
+ });
+ }, [options]);
+
+ useEffect(() => {
+ if (options.path === undefined) {
+ return;
+ }
+
+ // Reset selected when path changes
+ setSelected([]);
+
+ const client = new FsInfoClient(
+ `/${currentPath}`,
+ [
+ "type",
+ "mode",
+ "size",
+ "mtime",
+ "user",
+ "group",
+ "target",
+ "entries",
+ "targets",
+ ],
+ { superuser: "try" },
+ );
+
+ const disconnect = client.on("change", (state) => {
+ setLoading(false);
+ setLoadingFiles(!(state.info || state.error));
+ setCwdInfo(state.info || null);
+ setErrorMessage(state.error?.message ?? "");
+ const entries = Object.entries(state?.info?.entries || {});
+ const files = entries.map(([name, attrs]) => {
+ const to = FsInfoClient.target(state.info!, name)?.type ?? null;
+ const category =
+ to === "reg" ? filetype_lookup(filetype_data, name) : null;
+ return { ...attrs, name, to, category };
+ });
+ setFiles(files);
+ });
+
+ return () => {
+ disconnect();
+ client.close();
+ };
+ }, [options, currentPath]);
+
+ if (loading) return ;
+
+ const addAlert = (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => {
+ setAlerts((prevAlerts) => [
+ ...prevAlerts,
+ { title, variant, key, ...(detail && { detail }) },
+ ]);
+ };
+ const removeAlert = (key: string) =>
+ setAlerts((prevAlerts) =>
+ prevAlerts.filter((alert) => alert.key !== key),
+ );
+
+ return (
+
+
+
+
+ {alerts.map((alert) => (
+ removeAlert(alert.key)}
+ />
+ }
+ key={alert.key}
+ >
+ {alert.detail}
+
+ ))}
+
+
+
+
+
+ {errorMessage && (
+
+
+
+ )}
+ {!errorMessage && (
+
+ )}
+
+
+
+ files.find(
+ (f) => f.name === s.name,
+ ),
+ )
+ .filter((s) => s !== undefined)}
+ showHidden={showHidden}
+ setSelected={setSelected}
+ clipboard={clipboard}
+ setClipboard={setClipboard}
+ files={files}
+ />
+
+
+
+
+
+
+ );
};
diff --git a/src/common.ts b/src/common.ts
index 5a4f53e8..34785eb0 100644
--- a/src/common.ts
+++ b/src/common.ts
@@ -22,41 +22,41 @@ import cockpit from "cockpit";
const _ = cockpit.gettext;
export const permissions = [
- /* 0 */ _("None"),
- /* 1 */ _("Execute-only"),
- /* 2 */ _("Write-only"),
- /* 3 */ _("Write and execute"),
- /* 4 */ _("Read-only"),
- /* 5 */ _("Read and execute"),
- /* 6 */ _("Read and write"),
- /* 7 */ _("Read, write and execute"),
+ /* 0 */ _("None"),
+ /* 1 */ _("Execute-only"),
+ /* 2 */ _("Write-only"),
+ /* 3 */ _("Write and execute"),
+ /* 4 */ _("Read-only"),
+ /* 5 */ _("Read and execute"),
+ /* 6 */ _("Read and write"),
+ /* 7 */ _("Read, write and execute"),
];
export const inode_types = {
- blk: _("Block device"),
- chr: _("Character device"),
- dir: _("Directory"),
- fifo: _("Named pipe"),
- lnk: _("Symbolic link"),
- reg: _("Regular file"),
- sock: _("Socket"),
+ blk: _("Block device"),
+ chr: _("Character device"),
+ dir: _("Directory"),
+ fifo: _("Named pipe"),
+ lnk: _("Symbolic link"),
+ reg: _("Regular file"),
+ sock: _("Socket"),
};
export function get_permissions(n: number) {
- return permissions[n & 0o7];
+ return permissions[n & 0o7];
}
export function* map_permissions(func: (value: number, label: string) => T) {
- for (const [value, label] of permissions.entries()) {
- yield func(value, label);
- }
+ for (const [value, label] of permissions.entries()) {
+ yield func(value, label);
+ }
}
export function basename(path: string) {
- const elements = path.split("/");
- if (elements.length === 0) {
- return "/";
- } else {
- return elements[elements.length - 1];
- }
+ const elements = path.split("/");
+ if (elements.length === 0) {
+ return "/";
+ } else {
+ return elements[elements.length - 1];
+ }
}
diff --git a/src/dialogs/delete.tsx b/src/dialogs/delete.tsx
index 4f3b8f39..cfd0c7d5 100644
--- a/src/dialogs/delete.tsx
+++ b/src/dialogs/delete.tsx
@@ -21,8 +21,8 @@ import React, { useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
- Modal,
- ModalVariant,
+ Modal,
+ ModalVariant,
} from "@patternfly/react-core/dist/esm/components/Modal";
import cockpit from "cockpit";
@@ -34,101 +34,108 @@ import type { FolderFileInfo } from "../app";
const _ = cockpit.gettext;
const ConfirmDeletionDialog = ({
- dialogResult,
- path,
- selected,
- setSelected,
+ dialogResult,
+ path,
+ selected,
+ setSelected,
}: {
- dialogResult: DialogResult;
- path: string;
- selected: FolderFileInfo[];
- setSelected: React.Dispatch>;
+ dialogResult: DialogResult;
+ path: string;
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
}) => {
- const [errorMessage, setErrorMessage] = useState(null);
- const [forceDelete, setForceDelete] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [forceDelete, setForceDelete] = useState(false);
- let modalTitle;
- if (selected.length > 1) {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete $0 items") : _("Delete $0 items?"),
- selected.length,
- );
- } else {
- const selectedItem = selected[0];
- if (selectedItem.type === "reg") {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete file $0?") : _("Delete file $0?"),
- selectedItem.name,
- );
- } else if (selectedItem.type === "lnk") {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete link $0?") : _("Delete link $0?"),
- selectedItem.name,
- );
- } else if (selectedItem.type === "dir") {
- modalTitle = cockpit.format(
- forceDelete
- ? _("Force delete directory $0?")
- : _("Delete directory $0?"),
- selectedItem.name,
- );
- } else {
- modalTitle = cockpit.format(
- forceDelete ? _("Force delete $0") : _("Delete $0?"),
- selectedItem.name,
- );
- }
- }
+ let modalTitle;
+ if (selected.length > 1) {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete $0 items") : _("Delete $0 items?"),
+ selected.length,
+ );
+ } else {
+ const selectedItem = selected[0];
+ if (selectedItem.type === "reg") {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete file $0?") : _("Delete file $0?"),
+ selectedItem.name,
+ );
+ } else if (selectedItem.type === "lnk") {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete link $0?") : _("Delete link $0?"),
+ selectedItem.name,
+ );
+ } else if (selectedItem.type === "dir") {
+ modalTitle = cockpit.format(
+ forceDelete
+ ? _("Force delete directory $0?")
+ : _("Delete directory $0?"),
+ selectedItem.name,
+ );
+ } else {
+ modalTitle = cockpit.format(
+ forceDelete ? _("Force delete $0") : _("Delete $0?"),
+ selectedItem.name,
+ );
+ }
+ }
- const deleteItem = () => {
- const args = ["rm", "-r"];
- // TODO: Make force more sensible https://github.com/cockpit-project/cockpit-files/issues/363
- cockpit
- .spawn([...args, ...selected.map((f) => path + f.name)], {
- err: "message",
- superuser: "try",
- })
- .then(() => {
- setSelected([]);
- dialogResult.resolve();
- })
- .catch((err) => {
- setErrorMessage(err.message);
- setForceDelete(true);
- });
- };
+ const deleteItem = () => {
+ const args = ["rm", "-r"];
+ // TODO: Make force more sensible https://github.com/cockpit-project/cockpit-files/issues/363
+ cockpit
+ .spawn([...args, ...selected.map((f) => path + f.name)], {
+ err: "message",
+ superuser: "try",
+ })
+ .then(() => {
+ setSelected([]);
+ dialogResult.resolve();
+ })
+ .catch((err) => {
+ setErrorMessage(err.message);
+ setForceDelete(true);
+ });
+ };
- return (
- dialogResult.resolve()}
- footer={
- <>
-
- {_("Delete")}
-
- dialogResult.resolve()}>
- {_("Cancel")}
-
- >
- }
- >
- {errorMessage && (
-
- )}
-
- );
+ return (
+ dialogResult.resolve()}
+ footer={
+ <>
+
+ {_("Delete")}
+
+ dialogResult.resolve()}
+ >
+ {_("Cancel")}
+
+ >
+ }
+ >
+ {errorMessage && (
+
+ )}
+
+ );
};
export function confirm_delete(
- dialogs: Dialogs,
- path: string,
- selected: FolderFileInfo[],
- setSelected: React.Dispatch>,
+ dialogs: Dialogs,
+ path: string,
+ selected: FolderFileInfo[],
+ setSelected: React.Dispatch>,
) {
- dialogs.run(ConfirmDeletionDialog, { path, selected, setSelected });
+ dialogs.run(ConfirmDeletionDialog, { path, selected, setSelected });
}
diff --git a/src/dialogs/mkdir.tsx b/src/dialogs/mkdir.tsx
index 8ab6f5cc..f2daebcd 100644
--- a/src/dialogs/mkdir.tsx
+++ b/src/dialogs/mkdir.tsx
@@ -21,16 +21,16 @@ import React, { useEffect, useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
- Form,
- FormGroup,
+ Form,
+ FormGroup,
} from "@patternfly/react-core/dist/esm/components/Form";
import {
- FormSelect,
- FormSelectOption,
+ FormSelect,
+ FormSelectOption,
} from "@patternfly/react-core/dist/esm/components/FormSelect";
import {
- Modal,
- ModalVariant,
+ Modal,
+ ModalVariant,
} from "@patternfly/react-core/dist/esm/components/Modal";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
@@ -47,143 +47,154 @@ import { get_owner_candidates } from "../ownership";
const _ = cockpit.gettext;
function check_name(candidate: string) {
- if (candidate === "") {
- return _("Directory name cannot be empty.");
- } else if (candidate.length >= 256) {
- return _("Directory name too long.");
- } else if (candidate.includes("/")) {
- return _("Directory name cannot include a /.");
- } else {
- return undefined;
- }
+ if (candidate === "") {
+ return _("Directory name cannot be empty.");
+ } else if (candidate.length >= 256) {
+ return _("Directory name too long.");
+ } else if (candidate.includes("/")) {
+ return _("Directory name cannot include a /.");
+ } else {
+ return undefined;
+ }
}
async function create_directory(path: string, owner?: string) {
- if (owner !== undefined) {
- const opts = { err: "message", superuser: "require" } as const;
- await cockpit.spawn(["mkdir", path], opts);
- await cockpit.spawn(["chown", owner, path], opts);
- } else {
- await cockpit.spawn(["mkdir", path], { err: "message" });
- }
+ if (owner !== undefined) {
+ const opts = { err: "message", superuser: "require" } as const;
+ await cockpit.spawn(["mkdir", path], opts);
+ await cockpit.spawn(["chown", owner, path], opts);
+ } else {
+ await cockpit.spawn(["mkdir", path], { err: "message" });
+ }
}
const CreateDirectoryModal = ({
- currentPath,
- dialogResult,
+ currentPath,
+ dialogResult,
}: {
- currentPath: string;
- dialogResult: DialogResult;
+ currentPath: string;
+ dialogResult: DialogResult;
}) => {
- const [name, setName] = useState("");
- const [nameError, setNameError] = useState();
- const [errorMessage, setErrorMessage] = useState();
- const [owner, setOwner] = useState();
- const [user, setUser] = useState();
- const createDirectory = () => {
- const path = currentPath + name;
- create_directory(path, owner).then(dialogResult.resolve, (err) =>
- setErrorMessage(err.message),
- );
- };
- const { cwdInfo } = useFilesContext();
+ const [name, setName] = useState("");
+ const [nameError, setNameError] = useState();
+ const [errorMessage, setErrorMessage] = useState();
+ const [owner, setOwner] = useState();
+ const [user, setUser] = useState();
+ const createDirectory = () => {
+ const path = currentPath + name;
+ create_directory(path, owner).then(dialogResult.resolve, (err) =>
+ setErrorMessage(err.message),
+ );
+ };
+ const { cwdInfo } = useFilesContext();
- useEffect(() => {
- cockpit.user().then((user) => setUser(user));
- }, []);
+ useEffect(() => {
+ cockpit.user().then((user) => setUser(user));
+ }, []);
- const candidates = [];
- if (superuser.allowed && user && cwdInfo) {
- candidates.push(...get_owner_candidates(user, cwdInfo));
- if (owner === undefined) {
- setOwner(candidates[0]);
- }
- }
+ const candidates = [];
+ if (superuser.allowed && user && cwdInfo) {
+ candidates.push(...get_owner_candidates(user, cwdInfo));
+ if (owner === undefined) {
+ setOwner(candidates[0]);
+ }
+ }
- return (
- dialogResult.resolve()}
- variant={ModalVariant.small}
- footer={
- <>
-
- {_("Create")}
-
- dialogResult.resolve()}>
- {_("Cancel")}
-
- >
- }
- >
-
- {errorMessage !== undefined && (
-
- )}
-
-
-
- );
+ return (
+ dialogResult.resolve()}
+ variant={ModalVariant.small}
+ footer={
+ <>
+
+ {_("Create")}
+
+ dialogResult.resolve()}
+ >
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
export function show_create_directory_dialog(
- dialogs: Dialogs,
- currentPath: string,
+ dialogs: Dialogs,
+ currentPath: string,
) {
- dialogs.run(CreateDirectoryModal, { currentPath });
+ dialogs.run(CreateDirectoryModal, { currentPath });
}
diff --git a/src/dialogs/permissions.jsx b/src/dialogs/permissions.jsx
index 3595a1fb..57f85fbf 100644
--- a/src/dialogs/permissions.jsx
+++ b/src/dialogs/permissions.jsx
@@ -21,17 +21,17 @@ import React, { useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
- Form,
- FormGroup,
- FormSection,
+ Form,
+ FormGroup,
+ FormSection,
} from "@patternfly/react-core/dist/esm/components/Form";
import {
- FormSelect,
- FormSelectOption,
+ FormSelect,
+ FormSelectOption,
} from "@patternfly/react-core/dist/esm/components/FormSelect";
import {
- Modal,
- ModalVariant,
+ Modal,
+ ModalVariant,
} from "@patternfly/react-core/dist/esm/components/Modal";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
@@ -47,204 +47,218 @@ import { map_permissions, inode_types } from "../common";
const _ = cockpit.gettext;
const EditPermissionsModal = ({ dialogResult, selected, path }) => {
- const { cwdInfo } = useFilesContext();
-
- // Nothing selected means we act on the current working directory
- if (!selected) {
- const directory_name = path[path.length - 1];
- selected = { ...cwdInfo, isCwd: true, name: directory_name };
- }
-
- const [owner, setOwner] = useState(selected.user);
- const [mode, setMode] = useState(selected.mode);
- const [group, setGroup] = useState(selected.group);
- const [errorMessage, setErrorMessage] = useState(undefined);
- const [accounts, setAccounts] = useState(null);
- const [groups, setGroups] = useState(null);
-
- useInit(async () => {
- try {
- const passwd = await cockpit.spawn(["getent", "passwd"], {
- err: "message",
- });
- setAccounts(etc_passwd_syntax.parse(passwd));
- } catch (exc) {
- console.error("Cannot obtain users from getent passwd", exc);
- }
-
- try {
- const group = await cockpit.spawn(["getent", "group"], {
- err: "message",
- });
- setGroups(etc_group_syntax.parse(group));
- } catch (exc) {
- console.error("Cannot obtain users from getent group", exc);
- }
- });
-
- const changeOwner = (owner) => {
- setOwner(owner);
- const currentOwner = accounts.find((a) => a.name === owner);
- const currentGroup = groups.find((g) => g.name === group);
- if (
- currentGroup?.gid !== currentOwner?.gid &&
- !currentGroup?.userlist.includes(currentOwner?.name)
- ) {
- setGroup(groups.find((g) => g.gid === currentOwner.gid).name);
- }
- };
-
- const spawnEditPermissions = async () => {
- const permissionChanged = mode !== selected.mode;
- const ownerChanged = owner !== selected.user || group !== selected.group;
-
- try {
- const directory = selected?.isCwd
- ? path.join("/")
- : path.join("/") + "/" + selected.name;
- if (permissionChanged)
- await cockpit.spawn(["chmod", mode.toString(8), directory], {
- superuser: "try",
- err: "message",
- });
-
- if (ownerChanged)
- await cockpit.spawn(["chown", owner + ":" + group, directory], {
- superuser: "try",
- err: "message",
- });
-
- dialogResult.resolve();
- } catch (err) {
- setErrorMessage(err.message);
- }
- };
-
- function permissions_options() {
- return [
- ...map_permissions((value, label) => (
-
- )),
- ];
- }
-
- function sortByName(a, b) {
- return a.name.localeCompare(b.name);
- }
-
- return (
-
- spawnEditPermissions()}>
- {_("Change")}
-
-
- {_("Cancel")}
-
- >
- }
- >
-
- {errorMessage !== undefined && (
-
- )}
-
-
-
- );
+ const { cwdInfo } = useFilesContext();
+
+ // Nothing selected means we act on the current working directory
+ if (!selected) {
+ const directory_name = path[path.length - 1];
+ selected = { ...cwdInfo, isCwd: true, name: directory_name };
+ }
+
+ const [owner, setOwner] = useState(selected.user);
+ const [mode, setMode] = useState(selected.mode);
+ const [group, setGroup] = useState(selected.group);
+ const [errorMessage, setErrorMessage] = useState(undefined);
+ const [accounts, setAccounts] = useState(null);
+ const [groups, setGroups] = useState(null);
+
+ useInit(async () => {
+ try {
+ const passwd = await cockpit.spawn(["getent", "passwd"], {
+ err: "message",
+ });
+ setAccounts(etc_passwd_syntax.parse(passwd));
+ } catch (exc) {
+ console.error("Cannot obtain users from getent passwd", exc);
+ }
+
+ try {
+ const group = await cockpit.spawn(["getent", "group"], {
+ err: "message",
+ });
+ setGroups(etc_group_syntax.parse(group));
+ } catch (exc) {
+ console.error("Cannot obtain users from getent group", exc);
+ }
+ });
+
+ const changeOwner = (owner) => {
+ setOwner(owner);
+ const currentOwner = accounts.find((a) => a.name === owner);
+ const currentGroup = groups.find((g) => g.name === group);
+ if (
+ currentGroup?.gid !== currentOwner?.gid &&
+ !currentGroup?.userlist.includes(currentOwner?.name)
+ ) {
+ setGroup(groups.find((g) => g.gid === currentOwner.gid).name);
+ }
+ };
+
+ const spawnEditPermissions = async () => {
+ const permissionChanged = mode !== selected.mode;
+ const ownerChanged =
+ owner !== selected.user || group !== selected.group;
+
+ try {
+ const directory = selected?.isCwd
+ ? path.join("/")
+ : path.join("/") + "/" + selected.name;
+ if (permissionChanged)
+ await cockpit.spawn(["chmod", mode.toString(8), directory], {
+ superuser: "try",
+ err: "message",
+ });
+
+ if (ownerChanged)
+ await cockpit.spawn(["chown", owner + ":" + group, directory], {
+ superuser: "try",
+ err: "message",
+ });
+
+ dialogResult.resolve();
+ } catch (err) {
+ setErrorMessage(err.message);
+ }
+ };
+
+ function permissions_options() {
+ return [
+ ...map_permissions((value, label) => (
+
+ )),
+ ];
+ }
+
+ function sortByName(a, b) {
+ return a.name.localeCompare(b.name);
+ }
+
+ return (
+
+ spawnEditPermissions()}
+ >
+ {_("Change")}
+
+
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
export function edit_permissions(dialogs, selected, path) {
- dialogs.run(EditPermissionsModal, { selected, path });
+ dialogs.run(EditPermissionsModal, { selected, path });
}
diff --git a/src/dialogs/rename.tsx b/src/dialogs/rename.tsx
index efd18f13..65c2707c 100644
--- a/src/dialogs/rename.tsx
+++ b/src/dialogs/rename.tsx
@@ -21,12 +21,12 @@ import React, { useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
- Form,
- FormGroup,
+ Form,
+ FormGroup,
} from "@patternfly/react-core/dist/esm/components/Form";
import {
- Modal,
- ModalVariant,
+ Modal,
+ ModalVariant,
} from "@patternfly/react-core/dist/esm/components/Modal";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
@@ -43,158 +43,178 @@ import { FolderFileInfo, useFilesContext } from "../app";
const _ = cockpit.gettext;
function checkName(
- candidate: string,
- entries: Record,
- selectedFile: FolderFileInfo,
+ candidate: string,
+ entries: Record,
+ selectedFile: FolderFileInfo,
) {
- if (candidate === "") {
- return _("Name cannot be empty.");
- } else if (candidate.length >= 256) {
- return _("Name too long.");
- } else if (candidate.includes("/")) {
- return _("Name cannot include a /.");
- } else if (selectedFile.name === candidate) {
- return _("Filename is the same as original name");
- } else if (candidate in entries) {
- if (entries[candidate].type === "dir") {
- return _("Directory with the same name exists");
- }
- return _("File exists");
- } else {
- return null;
- }
+ if (candidate === "") {
+ return _("Name cannot be empty.");
+ } else if (candidate.length >= 256) {
+ return _("Name too long.");
+ } else if (candidate.includes("/")) {
+ return _("Name cannot include a /.");
+ } else if (selectedFile.name === candidate) {
+ return _("Filename is the same as original name");
+ } else if (candidate in entries) {
+ if (entries[candidate].type === "dir") {
+ return _("Directory with the same name exists");
+ }
+ return _("File exists");
+ } else {
+ return null;
+ }
}
function checkCanOverride(
- candidate: string,
- entries: Record,
- selectedFile: FolderFileInfo,
+ candidate: string,
+ entries: Record,
+ selectedFile: FolderFileInfo,
) {
- if (candidate in entries) {
- const conflictFile = entries[candidate];
- // only allow overwriting regular files
- if (conflictFile.type !== "reg") {
- return false;
- }
-
- // don't allow overwrite when the filename is unchanged
- if (selectedFile.type === "reg" && candidate !== selectedFile.name) {
- return true;
- }
- }
-
- return false;
+ if (candidate in entries) {
+ const conflictFile = entries[candidate];
+ // only allow overwriting regular files
+ if (conflictFile.type !== "reg") {
+ return false;
+ }
+
+ // don't allow overwrite when the filename is unchanged
+ if (selectedFile.type === "reg" && candidate !== selectedFile.name) {
+ return true;
+ }
+ }
+
+ return false;
}
const RenameItemModal = ({
- dialogResult,
- path,
- selected,
+ dialogResult,
+ path,
+ selected,
}: {
- dialogResult: DialogResult;
- path: string[];
- selected: FolderFileInfo;
+ dialogResult: DialogResult;
+ path: string[];
+ selected: FolderFileInfo;
}) => {
- const { cwdInfo } = useFilesContext();
- const [name, setName] = useState(selected.name);
- const [nameError, setNameError] = useState(null);
- const [errorMessage, setErrorMessage] = useState(
- undefined,
- );
- const [overrideFileName, setOverrideFileName] = useState(false);
-
- const renameItem = (force = false) => {
- const newPath = path.join("/") + "/" + name;
- const mvCmd = ["mv", "--no-target-directory"];
- if (force) {
- mvCmd.push("--force");
- }
- mvCmd.push(path.join("/") + "/" + selected.name, newPath);
-
- cockpit.spawn(mvCmd, { superuser: "try", err: "message" }).then(
- () => {
- dialogResult.resolve();
- },
- (err) => setErrorMessage(err.message),
- );
- };
-
- const footer = (
- <>
- renameItem()}
- isDisabled={errorMessage !== undefined || nameError !== null}
- >
- {_("Rename")}
-
- {overrideFileName && (
- renameItem(true)}>
- {_("Overwrite")}
-
- )}
- dialogResult.resolve()}>
- {_("Cancel")}
-
- >
- );
-
- const label = selected.type !== "dir" ? _("New filename") : _("New name");
-
- return (
- {selected.name})}
- variant={ModalVariant.small}
- isOpen
- onClose={() => dialogResult.resolve()}
- footer={footer}
- >
-
- {errorMessage !== undefined && (
-
- )}
-
-
-
- );
+ const { cwdInfo } = useFilesContext();
+ const [name, setName] = useState(selected.name);
+ const [nameError, setNameError] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(
+ undefined,
+ );
+ const [overrideFileName, setOverrideFileName] = useState(false);
+
+ const renameItem = (force = false) => {
+ const newPath = path.join("/") + "/" + name;
+ const mvCmd = ["mv", "--no-target-directory"];
+ if (force) {
+ mvCmd.push("--force");
+ }
+ mvCmd.push(path.join("/") + "/" + selected.name, newPath);
+
+ cockpit.spawn(mvCmd, { superuser: "try", err: "message" }).then(
+ () => {
+ dialogResult.resolve();
+ },
+ (err) => setErrorMessage(err.message),
+ );
+ };
+
+ const footer = (
+ <>
+ renameItem()}
+ isDisabled={errorMessage !== undefined || nameError !== null}
+ >
+ {_("Rename")}
+
+ {overrideFileName && (
+ renameItem(true)}>
+ {_("Overwrite")}
+
+ )}
+ dialogResult.resolve()}>
+ {_("Cancel")}
+
+ >
+ );
+
+ const label = selected.type !== "dir" ? _("New filename") : _("New name");
+
+ return (
+ {selected.name})}
+ variant={ModalVariant.small}
+ isOpen
+ onClose={() => dialogResult.resolve()}
+ footer={footer}
+ >
+
+ {errorMessage !== undefined && (
+
+ )}
+
+
+
+ );
};
export function show_rename_dialog(
- dialogs: Dialogs,
- path: string[],
- selected: FolderFileInfo,
+ dialogs: Dialogs,
+ path: string[],
+ selected: FolderFileInfo,
) {
- dialogs.run(RenameItemModal, { path, selected });
+ dialogs.run(RenameItemModal, { path, selected });
}
diff --git a/src/download.tsx b/src/download.tsx
index cc16312b..9b81f664 100644
--- a/src/download.tsx
+++ b/src/download.tsx
@@ -22,22 +22,22 @@ import cockpit from "cockpit";
import type { FolderFileInfo } from "./app";
export function downloadFile(currentPath: string, selected: FolderFileInfo) {
- const payload = JSON.stringify({
- payload: "fsread1",
- binary: "raw",
- path: `${currentPath}/${selected.name}`,
- superuser: "try",
- external: {
- "content-disposition": `attachment; filename="${selected.name}"`,
- "content-type": "application/octet-stream",
- },
- });
+ const payload = JSON.stringify({
+ payload: "fsread1",
+ binary: "raw",
+ path: `${currentPath}/${selected.name}`,
+ superuser: "try",
+ external: {
+ "content-disposition": `attachment; filename="${selected.name}"`,
+ "content-type": "application/octet-stream",
+ },
+ });
- const encodedPayload = new TextEncoder().encode(payload);
- const query = window.btoa(String.fromCharCode(...encodedPayload));
+ const encodedPayload = new TextEncoder().encode(payload);
+ const query = window.btoa(String.fromCharCode(...encodedPayload));
- const prefix = new URL(
- cockpit.transport.uri("channel/" + cockpit.transport.csrf_token),
- ).pathname;
- window.open(`${prefix}?${query}`);
+ const prefix = new URL(
+ cockpit.transport.uri("channel/" + cockpit.transport.csrf_token),
+ ).pathname;
+ window.open(`${prefix}?${query}`);
}
diff --git a/src/files-breadcrumbs.tsx b/src/files-breadcrumbs.tsx
index be805e6d..c13f2590 100644
--- a/src/files-breadcrumbs.tsx
+++ b/src/files-breadcrumbs.tsx
@@ -22,28 +22,28 @@ import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import {
- Dropdown,
- DropdownItem,
- DropdownList,
+ Dropdown,
+ DropdownItem,
+ DropdownList,
} from "@patternfly/react-core/dist/esm/components/Dropdown";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon";
import {
- MenuToggle,
- MenuToggleElement,
+ MenuToggle,
+ MenuToggleElement,
} from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { PageBreadcrumb } from "@patternfly/react-core/dist/esm/components/Page";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
import {
- Tooltip,
- TooltipPosition,
+ Tooltip,
+ TooltipPosition,
} from "@patternfly/react-core/dist/esm/components/Tooltip";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
import {
- CheckIcon,
- HddIcon,
- PencilAltIcon,
- StarIcon,
- TimesIcon,
+ CheckIcon,
+ HddIcon,
+ PencilAltIcon,
+ StarIcon,
+ TimesIcon,
} from "@patternfly/react-icons";
import { useInit } from "hooks.js";
@@ -56,363 +56,384 @@ import { basename } from "./common";
const _ = cockpit.gettext;
function useHostname() {
- const [hostname, setHostname] = React.useState(null);
-
- React.useEffect(() => {
- const client = cockpit.dbus("org.freedesktop.hostname1");
- const hostname1 = client.proxy(
- "org.freedesktop.hostname1",
- "/org/freedesktop/hostname1",
- );
-
- function changed() {
- if (hostname1.valid && typeof hostname1.Hostname === "string") {
- setHostname(hostname1.Hostname);
- }
- }
-
- hostname1.addEventListener("changed", changed);
- return () => {
- hostname1.removeEventListener("changed", changed);
- client.close();
- };
- }, []);
-
- return hostname;
+ const [hostname, setHostname] = React.useState(null);
+
+ React.useEffect(() => {
+ const client = cockpit.dbus("org.freedesktop.hostname1");
+ const hostname1 = client.proxy(
+ "org.freedesktop.hostname1",
+ "/org/freedesktop/hostname1",
+ );
+
+ function changed() {
+ if (hostname1.valid && typeof hostname1.Hostname === "string") {
+ setHostname(hostname1.Hostname);
+ }
+ }
+
+ hostname1.addEventListener("changed", changed);
+ return () => {
+ hostname1.removeEventListener("changed", changed);
+ client.close();
+ };
+ }, []);
+
+ return hostname;
}
function BookmarkButton({ path }: { path: string[] }) {
- const [isOpen, setIsOpen] = React.useState(false);
- const [user, setUser] = React.useState(null);
- const [bookmarks, setBookmarks] = React.useState([]);
- const [bookmarkHandle, setBookmarkHandle] =
- React.useState | null>(null);
-
- const { addAlert, cwdInfo } = useFilesContext();
-
- const currentPath = path.join("/") || "/";
- const defaultBookmarks = [];
- if (user?.home) defaultBookmarks.push({ name: _("Home"), loc: user?.home }); // TODO: add trash
-
- const parse_uri = (line: string) => {
- // Drop everything after the space, we don't show renames
- line = line.replace(/\s.*/, "");
-
- // Drop the file:/// prefix
- line = line.replace("file://", "");
-
- // Nautilus decodes urls as paths can contain spaces
- return line
- .split("/")
- .map((part) => decodeURIComponent(part))
- .join("/");
- };
-
- useInit(async () => {
- const user_info = await cockpit.user();
- setUser(user_info);
-
- const handle = cockpit.file(`${user_info.home}/.config/gtk-3.0/bookmarks`);
- setBookmarkHandle(handle);
-
- handle.watch((content) => {
- if (content !== null) {
- setBookmarks(
- content
- .trim()
- .split("\n")
- .filter((line) => line.startsWith("file://"))
- .map(parse_uri),
- );
- } else {
- setBookmarks([]);
- }
- });
-
- return [handle];
- });
-
- const saveBookmark = async () => {
- cockpit.assert(user !== null, "user is null while saving bookmarks");
- cockpit.assert(
- bookmarkHandle !== null,
- "bookmarkHandle is null while saving bookmarks",
- );
- const bookmark_file = basename(bookmarkHandle.path);
- const config_dir = bookmarkHandle.path.replace(bookmark_file, "");
-
- try {
- await cockpit.spawn(["mkdir", "-p", config_dir]);
- } catch (err) {
- const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
- addAlert(
- _("Unable to create bookmark directory"),
- AlertVariant.danger,
- "bookmark-error",
- exc.message,
- );
- return;
- }
-
- try {
- await bookmarkHandle.modify((old_content: string) => {
- if (bookmarks.includes(currentPath)) {
- return old_content
- .split("\n")
- .filter((line) => parse_uri(line) !== currentPath)
- .join("\n");
- } else {
- const newBoomark =
- "file://" +
- path.map((part) => encodeURIComponent(part)).join("/") +
- "\n";
- return (old_content || "") + newBoomark;
- }
- });
- } catch (err) {
- const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
- addAlert(
- _("Unable to save bookmark file"),
- AlertVariant.danger,
- "bookmark-error",
- exc.message,
- );
- }
- };
-
- const handleSelect = (
- _event: React.MouseEvent | undefined,
- value: string | number | undefined,
- ) => {
- if (value === "bookmark-action") {
- saveBookmark();
- } else {
- cockpit.location.go("/", { path: encodeURIComponent(value as string) });
- }
- setIsOpen(false);
- };
-
- if (user === null) return null;
-
- let actionText = null;
- if (currentPath !== user.home) {
- if (bookmarks.includes(currentPath)) {
- actionText = _("Remove current directory");
- } else if (cwdInfo !== null) {
- actionText = _("Add bookmark");
- }
- }
-
- return (
- setIsOpen(isOpen)}
- onSelect={handleSelect}
- toggle={(toggleRef: React.Ref) => (
-
- }
- ref={toggleRef}
- onClick={() => setIsOpen(!isOpen)}
- isExpanded={isOpen}
- />
-
- )}
- >
-
- {defaultBookmarks.map((defaultBookmark) => (
-
- {defaultBookmark.name}
-
- ))}
- {bookmarks.length !== 0 && }
- {bookmarks.map((bookmark: string) => (
-
- {bookmark}
-
- ))}
- {actionText !== null && (
- <>
-
-
- {actionText}
-
- >
- )}
-
-
- );
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [user, setUser] = React.useState(null);
+ const [bookmarks, setBookmarks] = React.useState([]);
+ const [bookmarkHandle, setBookmarkHandle] =
+ React.useState | null>(null);
+
+ const { addAlert, cwdInfo } = useFilesContext();
+
+ const currentPath = path.join("/") || "/";
+ const defaultBookmarks = [];
+ if (user?.home) defaultBookmarks.push({ name: _("Home"), loc: user?.home }); // TODO: add trash
+
+ const parse_uri = (line: string) => {
+ // Drop everything after the space, we don't show renames
+ line = line.replace(/\s.*/, "");
+
+ // Drop the file:/// prefix
+ line = line.replace("file://", "");
+
+ // Nautilus decodes urls as paths can contain spaces
+ return line
+ .split("/")
+ .map((part) => decodeURIComponent(part))
+ .join("/");
+ };
+
+ useInit(async () => {
+ const user_info = await cockpit.user();
+ setUser(user_info);
+
+ const handle = cockpit.file(
+ `${user_info.home}/.config/gtk-3.0/bookmarks`,
+ );
+ setBookmarkHandle(handle);
+
+ handle.watch((content) => {
+ if (content !== null) {
+ setBookmarks(
+ content
+ .trim()
+ .split("\n")
+ .filter((line) => line.startsWith("file://"))
+ .map(parse_uri),
+ );
+ } else {
+ setBookmarks([]);
+ }
+ });
+
+ return [handle];
+ });
+
+ const saveBookmark = async () => {
+ cockpit.assert(user !== null, "user is null while saving bookmarks");
+ cockpit.assert(
+ bookmarkHandle !== null,
+ "bookmarkHandle is null while saving bookmarks",
+ );
+ const bookmark_file = basename(bookmarkHandle.path);
+ const config_dir = bookmarkHandle.path.replace(bookmark_file, "");
+
+ try {
+ await cockpit.spawn(["mkdir", "-p", config_dir]);
+ } catch (err) {
+ const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
+ addAlert(
+ _("Unable to create bookmark directory"),
+ AlertVariant.danger,
+ "bookmark-error",
+ exc.message,
+ );
+ return;
+ }
+
+ try {
+ await bookmarkHandle.modify((old_content: string) => {
+ if (bookmarks.includes(currentPath)) {
+ return old_content
+ .split("\n")
+ .filter((line) => parse_uri(line) !== currentPath)
+ .join("\n");
+ } else {
+ const newBoomark =
+ "file://" +
+ path.map((part) => encodeURIComponent(part)).join("/") +
+ "\n";
+ return (old_content || "") + newBoomark;
+ }
+ });
+ } catch (err) {
+ const exc = err as cockpit.BasicError; // HACK: You can't easily type an error in typescript
+ addAlert(
+ _("Unable to save bookmark file"),
+ AlertVariant.danger,
+ "bookmark-error",
+ exc.message,
+ );
+ }
+ };
+
+ const handleSelect = (
+ _event: React.MouseEvent | undefined,
+ value: string | number | undefined,
+ ) => {
+ if (value === "bookmark-action") {
+ saveBookmark();
+ } else {
+ cockpit.location.go("/", {
+ path: encodeURIComponent(value as string),
+ });
+ }
+ setIsOpen(false);
+ };
+
+ if (user === null) return null;
+
+ let actionText = null;
+ if (currentPath !== user.home) {
+ if (bookmarks.includes(currentPath)) {
+ actionText = _("Remove current directory");
+ } else if (cwdInfo !== null) {
+ actionText = _("Add bookmark");
+ }
+ }
+
+ return (
+ setIsOpen(isOpen)}
+ onSelect={handleSelect}
+ toggle={(toggleRef: React.Ref) => (
+
+ }
+ ref={toggleRef}
+ onClick={() => setIsOpen(!isOpen)}
+ isExpanded={isOpen}
+ />
+
+ )}
+ >
+
+ {defaultBookmarks.map((defaultBookmark) => (
+
+ {defaultBookmark.name}
+
+ ))}
+ {bookmarks.length !== 0 && }
+ {bookmarks.map((bookmark: string) => (
+
+ {bookmark}
+
+ ))}
+ {actionText !== null && (
+ <>
+
+
+ {actionText}
+
+ >
+ )}
+
+
+ );
}
// eslint-disable-next-line max-len
export function FilesBreadcrumbs({
- path,
- showHidden,
- setShowHidden,
+ path,
+ showHidden,
+ setShowHidden,
}: {
- path: string[];
- showHidden: boolean;
- setShowHidden: React.Dispatch>;
+ path: string[];
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
}) {
- const [editMode, setEditMode] = React.useState(false);
- const [newPath, setNewPath] = React.useState(null);
- const hostname = useHostname();
-
- function navigate(n_parts: number) {
- cockpit.location.go("/", {
- path: encodeURIComponent(path.slice(0, n_parts).join("/")),
- });
- }
-
- const handleInputKey = (event: React.KeyboardEvent) => {
- // Don't propogate navigation specific events
- if (
- event.key === "ArrowDown" ||
- event.key === "ArrowUp" ||
- event.key === "ArrowLeft" ||
- event.key === "ArrowRight" ||
- event.key === "Delete"
- ) {
- event.stopPropagation();
- }
- if (event.key === "Enter") {
- event.stopPropagation();
- changePath();
- } else if (event.key === "Escape") {
- cancelPathEdit();
- }
- };
-
- const enableEditMode = () => {
- setEditMode(true);
- setNewPath(path.join("/") || "/");
- };
-
- const changePath = () => {
- setEditMode(false);
- cockpit.assert(newPath !== null, "newPath cannot be null");
- // HACK: strip trailing / to circumvent the path being `//` in breadcrumbs
- cockpit.location.go("/", {
- path: encodeURIComponent(newPath.replace(/\/$/, "")),
- });
- setNewPath(null);
- };
-
- const cancelPathEdit = () => {
- setNewPath(null);
- setEditMode(false);
- };
-
- const onToggleHidden = () => {
- setShowHidden((prevShowHidden) => {
- localStorage.setItem(
- "files:showHiddenFiles",
- !showHidden ? "true" : "false",
- );
- return !prevShowHidden;
- });
- };
-
- const fullPath = path.slice(1);
- fullPath.unshift(hostname || "server");
-
- return (
-
-
-
- {!editMode && (
-
- }
- onClick={() => enableEditMode()}
- className="breadcrumb-button-edit"
- />
-
- )}
- {!editMode &&
- fullPath.map((dir, i) => {
- return (
-
- : null}
- variant="link"
- onClick={() => {
- navigate(i + 1);
- }}
- className={`breadcrumb-button breadcrumb-${i}`}
- >
- {dir || "/"}
-
- {dir !== "" && (
-
- /
-
- )}
-
- );
- })}
- {editMode && newPath !== null && (
-
- event.target.select()}
- onKeyDown={handleInputKey}
- onChange={(_event, value) => setNewPath(value)}
- />
-
- )}
-
- {editMode && (
- <>
- }
- onClick={changePath}
- className="breadcrumb-button-edit-apply"
- />
- }
- onClick={() => cancelPathEdit()}
- className="breadcrumb-button-edit-cancel"
- />
- >
- )}
-
-
- {_("Show hidden items")}
-
- {showHidden && (
-
-
-
- )}
-
-
- ,
- ]}
- />
-
-
-
- );
+ const [editMode, setEditMode] = React.useState(false);
+ const [newPath, setNewPath] = React.useState(null);
+ const hostname = useHostname();
+
+ function navigate(n_parts: number) {
+ cockpit.location.go("/", {
+ path: encodeURIComponent(path.slice(0, n_parts).join("/")),
+ });
+ }
+
+ const handleInputKey = (event: React.KeyboardEvent) => {
+ // Don't propogate navigation specific events
+ if (
+ event.key === "ArrowDown" ||
+ event.key === "ArrowUp" ||
+ event.key === "ArrowLeft" ||
+ event.key === "ArrowRight" ||
+ event.key === "Delete"
+ ) {
+ event.stopPropagation();
+ }
+ if (event.key === "Enter") {
+ event.stopPropagation();
+ changePath();
+ } else if (event.key === "Escape") {
+ cancelPathEdit();
+ }
+ };
+
+ const enableEditMode = () => {
+ setEditMode(true);
+ setNewPath(path.join("/") || "/");
+ };
+
+ const changePath = () => {
+ setEditMode(false);
+ cockpit.assert(newPath !== null, "newPath cannot be null");
+ // HACK: strip trailing / to circumvent the path being `//` in breadcrumbs
+ cockpit.location.go("/", {
+ path: encodeURIComponent(newPath.replace(/\/$/, "")),
+ });
+ setNewPath(null);
+ };
+
+ const cancelPathEdit = () => {
+ setNewPath(null);
+ setEditMode(false);
+ };
+
+ const onToggleHidden = () => {
+ setShowHidden((prevShowHidden) => {
+ localStorage.setItem(
+ "files:showHiddenFiles",
+ !showHidden ? "true" : "false",
+ );
+ return !prevShowHidden;
+ });
+ };
+
+ const fullPath = path.slice(1);
+ fullPath.unshift(hostname || "server");
+
+ return (
+
+
+
+ {!editMode && (
+
+ }
+ onClick={() => enableEditMode()}
+ className="breadcrumb-button-edit"
+ />
+
+ )}
+ {!editMode &&
+ fullPath.map((dir, i) => {
+ return (
+
+ : null}
+ variant="link"
+ onClick={() => {
+ navigate(i + 1);
+ }}
+ className={`breadcrumb-button breadcrumb-${i}`}
+ >
+ {dir || "/"}
+
+ {dir !== "" && (
+
+ /
+
+ )}
+
+ );
+ })}
+ {editMode && newPath !== null && (
+
+ event.target.select()}
+ onKeyDown={handleInputKey}
+ onChange={(_event, value) => setNewPath(value)}
+ />
+
+ )}
+
+ {editMode && (
+ <>
+
+ }
+ onClick={changePath}
+ className="breadcrumb-button-edit-apply"
+ />
+ }
+ onClick={() => cancelPathEdit()}
+ className="breadcrumb-button-edit-cancel"
+ />
+ >
+ )}
+
+
+
+ {_("Show hidden items")}
+
+
+ {showHidden && (
+
+
+
+ )}
+
+
+ ,
+ ]}
+ />
+
+
+
+ );
}
diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx
index b0a747e3..ce093266 100644
--- a/src/files-card-body.tsx
+++ b/src/files-card-body.tsx
@@ -18,29 +18,29 @@
*/
import React, {
- useCallback,
- useEffect,
- useMemo,
- useState,
- useRef,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useRef,
} from "react";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import {
- MenuItem,
- MenuList,
+ MenuItem,
+ MenuList,
} from "@patternfly/react-core/dist/esm/components/Menu";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { FolderIcon, SearchIcon } from "@patternfly/react-icons";
import {
- SortByDirection,
- Table,
- Thead,
- Tr,
- Th,
- Tbody,
- Td,
+ SortByDirection,
+ Table,
+ Thead,
+ Tr,
+ Th,
+ Tbody,
+ Td,
} from "@patternfly/react-table";
import cockpit from "cockpit";
@@ -59,472 +59,509 @@ import "./files-card-body.scss";
const _ = cockpit.gettext;
function compare(
- sortBy: Sort,
+ sortBy: Sort,
): (a: FolderFileInfo, b: FolderFileInfo) => number {
- const dir_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
- Number(b.to === "dir") - Number(a.to === "dir");
- const name_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
- a.name.localeCompare(b.name);
-
- // treat non-regular files and infos with missing 'size' field as having size of zero
- const size = (a: FolderFileInfo) => (a.type === "reg" && a.size) || 0;
- const mtime = (a: FolderFileInfo) => a.mtime || 0; // fallbak for missing .mtime field
-
- switch (sortBy) {
- case Sort.az:
- return (a, b) => dir_sort(a, b) || name_sort(a, b);
- case Sort.za:
- return (a, b) => dir_sort(a, b) || name_sort(b, a);
- case Sort.first_modified:
- return (a, b) => dir_sort(a, b) || mtime(a) - mtime(b) || name_sort(a, b);
- case Sort.last_modified:
- return (a, b) => dir_sort(a, b) || mtime(b) - mtime(a) || name_sort(a, b);
- case Sort.largest_size:
- return (a, b) => dir_sort(a, b) || size(b) - size(a) || name_sort(a, b);
- case Sort.smallest_size:
- return (a, b) => dir_sort(a, b) || size(a) - size(b) || name_sort(a, b);
- }
+ const dir_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
+ Number(b.to === "dir") - Number(a.to === "dir");
+ const name_sort = (a: FolderFileInfo, b: FolderFileInfo) =>
+ a.name.localeCompare(b.name);
+
+ // treat non-regular files and infos with missing 'size' field as having size of zero
+ const size = (a: FolderFileInfo) => (a.type === "reg" && a.size) || 0;
+ const mtime = (a: FolderFileInfo) => a.mtime || 0; // fallbak for missing .mtime field
+
+ switch (sortBy) {
+ case Sort.az:
+ return (a, b) => dir_sort(a, b) || name_sort(a, b);
+ case Sort.za:
+ return (a, b) => dir_sort(a, b) || name_sort(b, a);
+ case Sort.first_modified:
+ return (a, b) =>
+ dir_sort(a, b) || mtime(a) - mtime(b) || name_sort(a, b);
+ case Sort.last_modified:
+ return (a, b) =>
+ dir_sort(a, b) || mtime(b) - mtime(a) || name_sort(a, b);
+ case Sort.largest_size:
+ return (a, b) =>
+ dir_sort(a, b) || size(b) - size(a) || name_sort(a, b);
+ case Sort.smallest_size:
+ return (a, b) =>
+ dir_sort(a, b) || size(a) - size(b) || name_sort(a, b);
+ }
}
const ContextMenuItems = ({
- path,
- selected,
- setSelected,
- clipboard,
- setClipboard,
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
}: {
- path: string[];
- selected: FolderFileInfo[];
- setSelected: React.Dispatch>;
- clipboard: string[];
- setClipboard: React.Dispatch>;
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const dialogs = useDialogs();
- const { addAlert, cwdInfo } = useFilesContext();
- const menuItems = get_menu_items(
- path,
- selected,
- setSelected,
- clipboard,
- setClipboard,
- cwdInfo,
- addAlert,
- dialogs,
- );
-
- return (
-
- {menuItems.map((item, i) =>
- item.type !== "divider" ? (
-
- {item.title}
-
- ) : (
-
- ),
- )}
-
- );
+ const dialogs = useDialogs();
+ const { addAlert, cwdInfo } = useFilesContext();
+ const menuItems = get_menu_items(
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ cwdInfo,
+ addAlert,
+ dialogs,
+ );
+
+ return (
+
+ {menuItems.map((item, i) =>
+ item.type !== "divider" ? (
+
+ {item.title}
+
+ ) : (
+
+ ),
+ )}
+
+ );
};
export const FilesCardBody = ({
- currentFilter,
- setCurrentFilter,
- files,
- isGrid,
- path,
- selected,
- setSelected,
- sortBy,
- setSortBy,
- loadingFiles,
- clipboard,
- setClipboard,
- showHidden,
- setShowHidden,
+ currentFilter,
+ setCurrentFilter,
+ files,
+ isGrid,
+ path,
+ selected,
+ setSelected,
+ sortBy,
+ setSortBy,
+ loadingFiles,
+ clipboard,
+ setClipboard,
+ showHidden,
+ setShowHidden,
}: {
- currentFilter: string;
- setCurrentFilter: React.Dispatch>;
- files: FolderFileInfo[];
- isGrid: boolean;
- path: string[];
- selected: FolderFileInfo[];
- setSelected: React.Dispatch>;
- sortBy: Sort;
- setSortBy: React.Dispatch>;
- loadingFiles: boolean;
- clipboard: string[];
- setClipboard: React.Dispatch>;
- showHidden: boolean;
- setShowHidden: React.Dispatch>;
+ currentFilter: string;
+ setCurrentFilter: React.Dispatch>;
+ files: FolderFileInfo[];
+ isGrid: boolean;
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
+ loadingFiles: boolean;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
}) => {
- const [boxPerRow, setBoxPerRow] = useState(0);
- const dialogs = useDialogs();
-
- const sortedFiles = useMemo(() => {
- return files
- .filter((file) => (showHidden ? true : !file.name.startsWith(".")))
- .filter((file) =>
- file.name.toLowerCase().includes(currentFilter.toLowerCase()),
- )
- .sort(compare(sortBy));
- }, [files, showHidden, currentFilter, sortBy]);
- const hiddenFilesCount = useMemo(() => {
- return files.filter((file) => file.name.startsWith(".")).length;
- }, [files]);
- const isMounted = useRef();
- const folderViewRef = React.useRef(null);
-
- function calculateBoxPerRow() {
- const boxes = document.querySelectorAll(
- ".fileview tbody > tr",
- ) as NodeListOf;
- if (boxes.length > 1) {
- let i = 0;
- const total = boxes.length;
- const firstOffset = boxes[0].offsetTop;
- while (++i < total && boxes[i].offsetTop === firstOffset);
- setBoxPerRow(i);
- }
- }
-
- const onDoubleClickNavigate = useCallback(
- (file: FolderFileInfo) => {
- const newPath = [...path, file.name].join("/");
- if (file.to === "dir") {
- cockpit.location.go("/", { path: encodeURIComponent(newPath) });
- }
- },
- [path],
- );
-
- useEffect(() => {
- calculateBoxPerRow();
- window.onresize = calculateBoxPerRow;
- return () => {
- window.onresize = null;
- };
- });
-
- useEffect(() => {
- let folderViewElem = null;
-
- const resetSelected = (e: MouseEvent) => {
- if (e.target instanceof HTMLElement) {
- if (
- e.target.id === "folder-view" ||
- e.target.id === "files-card-parent" ||
- (e.target.parentElement &&
- e.target.parentElement.id === "folder-view")
- ) {
- if (selected.length !== 0) {
- setSelected([]);
- }
- }
- }
- };
-
- const handleDoubleClick = (ev: MouseEvent) => {
- ev.preventDefault();
- const name = getFilenameForEvent(ev);
- const file = sortedFiles?.find((file) => file.name === name);
- if (!file) return null;
- if (!file) {
- resetSelected(ev);
- return;
- }
-
- onDoubleClickNavigate(file);
- };
-
- const handleClick = (ev: MouseEvent) => {
- ev.preventDefault();
- const name = getFilenameForEvent(ev);
- const file = sortedFiles?.find((file) => file.name === name);
- if (!file) {
- resetSelected(ev);
- return;
- }
-
- if (ev.detail > 1) {
- onDoubleClickNavigate(file);
- } else {
- if (!ev.ctrlKey) {
- setSelected([file]);
- } else {
- setSelected((s) => {
- if (!s.find((f) => f.name === file.name)) {
- return [...s, file];
- } else {
- return s.filter((f) => f.name !== file.name);
- }
- });
- }
- }
- };
-
- const handleContextMenu = (event: MouseEvent) => {
- const name = getFilenameForEvent(event);
- if (name !== null && selected.length > 1) {
- return;
- }
-
- const sel = sortedFiles?.find((file) => file.name === name);
- if (sel) {
- setSelected([sel]);
- } else {
- setSelected([]);
- }
- };
-
- const onKeyboardNav = (e: KeyboardEvent) => {
- if (e.key === "ArrowRight") {
- setSelected((_selected) => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(
- (file) => file.name === firstSelectedName,
- );
- const newIdx =
- selectedIdx < sortedFiles.length - 1 ? selectedIdx + 1 : 0;
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowLeft") {
- setSelected((_selected) => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(
- (file) => file.name === firstSelectedName,
- );
- const newIdx =
- selectedIdx > 0 ? selectedIdx - 1 : sortedFiles.length - 1;
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowUp") {
- setSelected((_selected) => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(
- (file) => file.name === firstSelectedName,
- );
- const newIdx = Math.max(selectedIdx - boxPerRow, 0);
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "ArrowDown") {
- setSelected((_selected) => {
- const firstSelectedName = _selected?.[0]?.name;
- const selectedIdx = sortedFiles?.findIndex(
- (file) => file.name === firstSelectedName,
- );
- const newIdx = Math.min(
- selectedIdx + boxPerRow,
- sortedFiles.length - 1,
- );
-
- return [sortedFiles[newIdx]];
- });
- } else if (e.key === "Enter" && selected.length === 1) {
- onDoubleClickNavigate(selected[0]);
- } else if (e.key === "Delete" && selected.length !== 0) {
- confirm_delete(dialogs, path.join("/") + "/", selected, setSelected);
- }
- };
-
- if (folderViewRef.current) {
- folderViewElem = folderViewRef.current;
- folderViewElem.addEventListener("click", handleClick);
- folderViewElem.addEventListener("dblclick", handleDoubleClick);
- folderViewElem.addEventListener("contextmenu", handleContextMenu);
- }
-
- if (!isMounted.current && !dialogs.isActive()) {
- isMounted.current = true;
- document.addEventListener("keydown", onKeyboardNav);
- }
- if (dialogs.isActive())
- document.removeEventListener("keydown", onKeyboardNav);
- return () => {
- isMounted.current = false;
- document.removeEventListener("keydown", onKeyboardNav);
- if (folderViewElem) {
- folderViewElem.removeEventListener("click", handleClick);
- folderViewElem.removeEventListener("dblclick", handleDoubleClick);
- folderViewElem.removeEventListener("contextmenu", handleContextMenu);
- }
- };
- }, [
- setSelected,
- sortedFiles,
- boxPerRow,
- selected,
- onDoubleClickNavigate,
- dialogs,
- path,
- ]);
-
- // Generic event handler to look up the corresponding `data-item` for a click event when
- // a user clicks in the folder view. We use three event listeners (click,
- // doubleclick and rightclick) instead of having three event listeners per
- // item in the folder view. Having a lot of event listeners hurts
- // performance, this does require us to walk up the DOM until we find the
- // required `data-item` but this is a fairly trivial at the benefit of the
- // performance gains.
- const getFilenameForEvent = (event: Event) => {
- let data_item = null;
- let elem = event.target as HTMLElement;
- // Limit iterating to ten parents
- for (let i = 0; i < 10; i++) {
- data_item = elem.getAttribute("data-item");
- if (data_item) break;
-
- if (elem.parentElement) elem = elem.parentElement;
- else break;
- }
-
- return data_item;
- };
-
- if (loadingFiles)
- return (
-
-
-
- );
-
- const files_parent_id = "files-card-parent";
- const contextMenu = (
-
-
-
- );
-
- const sortColumn = (columnIndex: number) => ({
- sortBy: {
- index: filterColumnMapping[sortBy][0],
- direction: filterColumnMapping[sortBy][1],
- },
- onSort: (_event: unknown, index: number, direction: SortByDirection) => {
- setSortBy(filterColumns[index][direction].itemId);
- },
- columnIndex,
- });
-
- return (
-
- {contextMenu}
- {sortedFiles.length === 0 && currentFilter && (
-
setCurrentFilter("")}
- actionVariant="link"
- />
- )}
- {sortedFiles.length === 0 && !currentFilter && (
- setShowHidden(true)}
- actionVariant="link"
- />
- )}
- {sortedFiles.length !== 0 && (
-
-
-
-
- {_("Name")}
-
-
- {_("Size")}
-
-
- {_("Modified")}
-
-
-
-
- {sortedFiles.map((file, rowIndex) => (
- s.name === file.name)}
- />
- ))}
-
-
- )}
-
- );
+ const [boxPerRow, setBoxPerRow] = useState(0);
+ const dialogs = useDialogs();
+
+ const sortedFiles = useMemo(() => {
+ return files
+ .filter((file) => (showHidden ? true : !file.name.startsWith(".")))
+ .filter((file) =>
+ file.name.toLowerCase().includes(currentFilter.toLowerCase()),
+ )
+ .sort(compare(sortBy));
+ }, [files, showHidden, currentFilter, sortBy]);
+ const hiddenFilesCount = useMemo(() => {
+ return files.filter((file) => file.name.startsWith(".")).length;
+ }, [files]);
+ const isMounted = useRef();
+ const folderViewRef = React.useRef(null);
+
+ function calculateBoxPerRow() {
+ const boxes = document.querySelectorAll(
+ ".fileview tbody > tr",
+ ) as NodeListOf;
+ if (boxes.length > 1) {
+ let i = 0;
+ const total = boxes.length;
+ const firstOffset = boxes[0].offsetTop;
+ while (++i < total && boxes[i].offsetTop === firstOffset);
+ setBoxPerRow(i);
+ }
+ }
+
+ const onDoubleClickNavigate = useCallback(
+ (file: FolderFileInfo) => {
+ const newPath = [...path, file.name].join("/");
+ if (file.to === "dir") {
+ cockpit.location.go("/", { path: encodeURIComponent(newPath) });
+ }
+ },
+ [path],
+ );
+
+ useEffect(() => {
+ calculateBoxPerRow();
+ window.onresize = calculateBoxPerRow;
+ return () => {
+ window.onresize = null;
+ };
+ });
+
+ useEffect(() => {
+ let folderViewElem = null;
+
+ const resetSelected = (e: MouseEvent) => {
+ if (e.target instanceof HTMLElement) {
+ if (
+ e.target.id === "folder-view" ||
+ e.target.id === "files-card-parent" ||
+ (e.target.parentElement &&
+ e.target.parentElement.id === "folder-view")
+ ) {
+ if (selected.length !== 0) {
+ setSelected([]);
+ }
+ }
+ }
+ };
+
+ const handleDoubleClick = (ev: MouseEvent) => {
+ ev.preventDefault();
+ const name = getFilenameForEvent(ev);
+ const file = sortedFiles?.find((file) => file.name === name);
+ if (!file) return null;
+ if (!file) {
+ resetSelected(ev);
+ return;
+ }
+
+ onDoubleClickNavigate(file);
+ };
+
+ const handleClick = (ev: MouseEvent) => {
+ ev.preventDefault();
+ const name = getFilenameForEvent(ev);
+ const file = sortedFiles?.find((file) => file.name === name);
+ if (!file) {
+ resetSelected(ev);
+ return;
+ }
+
+ if (ev.detail > 1) {
+ onDoubleClickNavigate(file);
+ } else {
+ if (!ev.ctrlKey) {
+ setSelected([file]);
+ } else {
+ setSelected((s) => {
+ if (!s.find((f) => f.name === file.name)) {
+ return [...s, file];
+ } else {
+ return s.filter((f) => f.name !== file.name);
+ }
+ });
+ }
+ }
+ };
+
+ const handleContextMenu = (event: MouseEvent) => {
+ const name = getFilenameForEvent(event);
+ if (name !== null && selected.length > 1) {
+ return;
+ }
+
+ const sel = sortedFiles?.find((file) => file.name === name);
+ if (sel) {
+ setSelected([sel]);
+ } else {
+ setSelected([]);
+ }
+ };
+
+ const onKeyboardNav = (e: KeyboardEvent) => {
+ if (e.key === "ArrowRight") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx =
+ selectedIdx < sortedFiles.length - 1
+ ? selectedIdx + 1
+ : 0;
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowLeft") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx =
+ selectedIdx > 0
+ ? selectedIdx - 1
+ : sortedFiles.length - 1;
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowUp") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx = Math.max(selectedIdx - boxPerRow, 0);
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "ArrowDown") {
+ setSelected((_selected) => {
+ const firstSelectedName = _selected?.[0]?.name;
+ const selectedIdx = sortedFiles?.findIndex(
+ (file) => file.name === firstSelectedName,
+ );
+ const newIdx = Math.min(
+ selectedIdx + boxPerRow,
+ sortedFiles.length - 1,
+ );
+
+ return [sortedFiles[newIdx]];
+ });
+ } else if (e.key === "Enter" && selected.length === 1) {
+ onDoubleClickNavigate(selected[0]);
+ } else if (e.key === "Delete" && selected.length !== 0) {
+ confirm_delete(
+ dialogs,
+ path.join("/") + "/",
+ selected,
+ setSelected,
+ );
+ }
+ };
+
+ if (folderViewRef.current) {
+ folderViewElem = folderViewRef.current;
+ folderViewElem.addEventListener("click", handleClick);
+ folderViewElem.addEventListener("dblclick", handleDoubleClick);
+ folderViewElem.addEventListener("contextmenu", handleContextMenu);
+ }
+
+ if (!isMounted.current && !dialogs.isActive()) {
+ isMounted.current = true;
+ document.addEventListener("keydown", onKeyboardNav);
+ }
+ if (dialogs.isActive())
+ document.removeEventListener("keydown", onKeyboardNav);
+ return () => {
+ isMounted.current = false;
+ document.removeEventListener("keydown", onKeyboardNav);
+ if (folderViewElem) {
+ folderViewElem.removeEventListener("click", handleClick);
+ folderViewElem.removeEventListener(
+ "dblclick",
+ handleDoubleClick,
+ );
+ folderViewElem.removeEventListener(
+ "contextmenu",
+ handleContextMenu,
+ );
+ }
+ };
+ }, [
+ setSelected,
+ sortedFiles,
+ boxPerRow,
+ selected,
+ onDoubleClickNavigate,
+ dialogs,
+ path,
+ ]);
+
+ // Generic event handler to look up the corresponding `data-item` for a click event when
+ // a user clicks in the folder view. We use three event listeners (click,
+ // doubleclick and rightclick) instead of having three event listeners per
+ // item in the folder view. Having a lot of event listeners hurts
+ // performance, this does require us to walk up the DOM until we find the
+ // required `data-item` but this is a fairly trivial at the benefit of the
+ // performance gains.
+ const getFilenameForEvent = (event: Event) => {
+ let data_item = null;
+ let elem = event.target as HTMLElement;
+ // Limit iterating to ten parents
+ for (let i = 0; i < 10; i++) {
+ data_item = elem.getAttribute("data-item");
+ if (data_item) break;
+
+ if (elem.parentElement) elem = elem.parentElement;
+ else break;
+ }
+
+ return data_item;
+ };
+
+ if (loadingFiles)
+ return (
+
+
+
+ );
+
+ const files_parent_id = "files-card-parent";
+ const contextMenu = (
+
+
+
+ );
+
+ const sortColumn = (columnIndex: number) => ({
+ sortBy: {
+ index: filterColumnMapping[sortBy][0],
+ direction: filterColumnMapping[sortBy][1],
+ },
+ onSort: (
+ _event: unknown,
+ index: number,
+ direction: SortByDirection,
+ ) => {
+ setSortBy(filterColumns[index][direction].itemId);
+ },
+ columnIndex,
+ });
+
+ return (
+
+ {contextMenu}
+ {sortedFiles.length === 0 && currentFilter && (
+
setCurrentFilter("")}
+ actionVariant="link"
+ />
+ )}
+ {sortedFiles.length === 0 && !currentFilter && (
+ setShowHidden(true)}
+ actionVariant="link"
+ />
+ )}
+ {sortedFiles.length !== 0 && (
+
+
+
+
+ {_("Name")}
+
+
+ {_("Size")}
+
+
+ {_("Modified")}
+
+
+
+
+ {sortedFiles.map((file, rowIndex) => (
+ s.name === file.name,
+ )}
+ />
+ ))}
+
+
+ )}
+
+ );
};
const getFileType = (file: FolderFileInfo) => {
- if (file.to === "dir") {
- return "folder";
- } else if (file.category?.class) {
- return file.category.class;
- } else {
- return "file";
- }
+ if (file.to === "dir") {
+ return "folder";
+ } else if (file.category?.class) {
+ return file.category.class;
+ } else {
+ return "file";
+ }
};
// Memoize the Item component as rendering thousands of them on each render of parent component is costly.
const Row = React.memo(function Item({
- file,
- isSelected,
+ file,
+ isSelected,
}: {
- file: FolderFileInfo;
- isSelected: boolean;
+ file: FolderFileInfo;
+ isSelected: boolean;
}) {
- const fileType = getFileType(file);
- let className = fileType;
- if (isSelected) className += " row-selected";
- if (file.type === "lnk") className += " symlink";
-
- return (
-
-
- {file.name}
-
-
- {file.type === "reg" && cockpit.format_bytes(file.size)}
-
-
- {file.mtime ? timeformat.dateTime(file.mtime * 1000) : null}
-
-
- );
+ const fileType = getFileType(file);
+ let className = fileType;
+ if (isSelected) className += " row-selected";
+ if (file.type === "lnk") className += " symlink";
+
+ return (
+
+
+ {file.name}
+
+
+ {file.type === "reg" && cockpit.format_bytes(file.size)}
+
+
+ {file.mtime ? timeformat.dateTime(file.mtime * 1000) : null}
+
+
+ );
});
diff --git a/src/files-folder-view.tsx b/src/files-folder-view.tsx
index 479ec6e0..f2e605b0 100644
--- a/src/files-folder-view.tsx
+++ b/src/files-folder-view.tsx
@@ -26,70 +26,70 @@ import { FilesCardBody } from "./files-card-body";
import { as_sort, FilesCardHeader } from "./header";
export const FilesFolderView = ({
- path,
- files,
- loadingFiles,
- showHidden,
- selected,
- setSelected,
- clipboard,
- setClipboard,
- setShowHidden,
+ path,
+ files,
+ loadingFiles,
+ showHidden,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ setShowHidden,
}: {
- path: string[];
- files: FolderFileInfo[];
- loadingFiles: boolean;
- showHidden: boolean;
- setShowHidden: React.Dispatch>;
- selected: FolderFileInfo[];
- setSelected: React.Dispatch>;
- clipboard: string[];
- setClipboard: React.Dispatch>;
+ path: string[];
+ files: FolderFileInfo[];
+ loadingFiles: boolean;
+ showHidden: boolean;
+ setShowHidden: React.Dispatch>;
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const [currentFilter, setCurrentFilter] = useState("");
- const [isGrid, setIsGrid] = useState(
- localStorage.getItem("files:isGrid") !== "false",
- );
- const [sortBy, setSortBy] = useState(
- as_sort(localStorage.getItem("files:sort")),
- );
- const onFilterChange = (
- _event: React.FormEvent,
- value: string,
- ) => setCurrentFilter(value);
+ const [currentFilter, setCurrentFilter] = useState("");
+ const [isGrid, setIsGrid] = useState(
+ localStorage.getItem("files:isGrid") !== "false",
+ );
+ const [sortBy, setSortBy] = useState(
+ as_sort(localStorage.getItem("files:sort")),
+ );
+ const onFilterChange = (
+ _event: React.FormEvent,
+ value: string,
+ ) => setCurrentFilter(value);
- // Reset the search filter on path changes
- useEffect(() => {
- setCurrentFilter("");
- }, [path]);
+ // Reset the search filter on path changes
+ useEffect(() => {
+ setCurrentFilter("");
+ }, [path]);
- return (
-
-
-
-
- );
+ return (
+
+
+
+
+ );
};
diff --git a/src/filetype-lookup.ts b/src/filetype-lookup.ts
index 2811f10e..99a4a745 100644
--- a/src/filetype-lookup.ts
+++ b/src/filetype-lookup.ts
@@ -1,41 +1,41 @@
export enum Category {
- FILE = 0,
+ FILE = 0,
- ARCHIVE,
- AUDIO,
- CODE,
- IMAGE,
- TEXT,
- VIDEO,
+ ARCHIVE,
+ AUDIO,
+ CODE,
+ IMAGE,
+ TEXT,
+ VIDEO,
}
export interface CategoryMetadata extends Record {
- name: string;
- class: string;
+ name: string;
+ class: string;
}
export interface FileTypeData {
- categories: Record;
- extensions: Record;
- max_extension_length: number;
+ categories: Record;
+ extensions: Record;
+ max_extension_length: number;
}
export function filetype_lookup(filetype_data: FileTypeData, name: string) {
- // Never find a dot at offset '0' (the one for a hidden file), or one that
- // would produce an extension that we know not to be in the database.
- let offset = Math.max(
- 1,
- name.length - filetype_data.max_extension_length - 1,
- );
+ // Never find a dot at offset '0' (the one for a hidden file), or one that
+ // would produce an extension that we know not to be in the database.
+ let offset = Math.max(
+ 1,
+ name.length - filetype_data.max_extension_length - 1,
+ );
- // Allow finding extensions like '.tar.gz' (prefer longest first)
- while ((offset = name.indexOf(".", offset)) > 0) {
- offset++;
- const ext = name.substring(offset);
- if (ext in filetype_data.extensions) {
- return filetype_data.categories[filetype_data.extensions[ext]];
- }
- }
+ // Allow finding extensions like '.tar.gz' (prefer longest first)
+ while ((offset = name.indexOf(".", offset)) > 0) {
+ offset++;
+ const ext = name.substring(offset);
+ if (ext in filetype_data.extensions) {
+ return filetype_data.categories[filetype_data.extensions[ext]];
+ }
+ }
- return filetype_data.categories[Category.FILE];
+ return filetype_data.categories[Category.FILE];
}
diff --git a/src/filetype-plugin.ts b/src/filetype-plugin.ts
index 3fafcc8c..c446b0d1 100644
--- a/src/filetype-plugin.ts
+++ b/src/filetype-plugin.ts
@@ -31,184 +31,184 @@ import mime_db from "mime-db/db.json";
import { Category, FileTypeData } from "./filetype-lookup";
export function create_filetype_data(): FileTypeData {
- const categories = {
- [Category.FILE]: { name: "Unknown type", class: "file" },
- [Category.ARCHIVE]: { name: "Archive file", class: "archive-file" },
- [Category.AUDIO]: { name: "Audio file", class: "audio-file" },
- [Category.CODE]: { name: "Source code file", class: "code-file" },
- [Category.IMAGE]: { name: "Image file", class: "image-file" },
- [Category.TEXT]: { name: "Text file", class: "text-file" },
- [Category.VIDEO]: { name: "Video file", class: "video-file" },
- } as const;
+ const categories = {
+ [Category.FILE]: { name: "Unknown type", class: "file" },
+ [Category.ARCHIVE]: { name: "Archive file", class: "archive-file" },
+ [Category.AUDIO]: { name: "Audio file", class: "audio-file" },
+ [Category.CODE]: { name: "Source code file", class: "code-file" },
+ [Category.IMAGE]: { name: "Image file", class: "image-file" },
+ [Category.TEXT]: { name: "Text file", class: "text-file" },
+ [Category.VIDEO]: { name: "Video file", class: "video-file" },
+ } as const;
- const extensions: { [ext: string]: Category } = {};
- let max_extension_length = 0;
+ const extensions: { [ext: string]: Category } = {};
+ let max_extension_length = 0;
- function add_category_extensions(category: Category, exts: string[]) {
- for (let ext of exts) {
- if (ext[0] === ".") {
- ext = ext.substring(1);
- }
+ function add_category_extensions(category: Category, exts: string[]) {
+ for (let ext of exts) {
+ if (ext[0] === ".") {
+ ext = ext.substring(1);
+ }
- max_extension_length = Math.max(max_extension_length, ext.length);
- extensions[ext] = category;
- }
- }
+ max_extension_length = Math.max(max_extension_length, ext.length);
+ extensions[ext] = category;
+ }
+ }
- // Broad-stroke categorization based on 'mime-db' package
- for (const [mimetype, details] of Object.entries(mime_db)) {
- if ("extensions" in details) {
- if (mimetype.startsWith("image/")) {
- add_category_extensions(Category.IMAGE, details.extensions);
- } else if (mimetype.startsWith("audio/")) {
- add_category_extensions(Category.AUDIO, details.extensions);
- } else if (mimetype.startsWith("video/")) {
- add_category_extensions(Category.VIDEO, details.extensions);
- } else if (mimetype.startsWith("text/")) {
- add_category_extensions(Category.TEXT, details.extensions);
- }
- }
- }
+ // Broad-stroke categorization based on 'mime-db' package
+ for (const [mimetype, details] of Object.entries(mime_db)) {
+ if ("extensions" in details) {
+ if (mimetype.startsWith("image/")) {
+ add_category_extensions(Category.IMAGE, details.extensions);
+ } else if (mimetype.startsWith("audio/")) {
+ add_category_extensions(Category.AUDIO, details.extensions);
+ } else if (mimetype.startsWith("video/")) {
+ add_category_extensions(Category.VIDEO, details.extensions);
+ } else if (mimetype.startsWith("text/")) {
+ add_category_extensions(Category.TEXT, details.extensions);
+ }
+ }
+ }
- // Archives, scraped from https://en.wikipedia.org/wiki/List_of_archive_formats
- add_category_extensions(Category.ARCHIVE, [
- "7z",
- "F",
- "LBR",
- "Z",
- "a",
- "aar",
- "ace",
- "afa",
- "alz",
- "apk",
- "ar",
- "arc",
- "arc",
- "arj",
- "ark",
- "b1",
- "b6z",
- "ba",
- "bh",
- "br",
- "bz2",
- "cab",
- "car",
- "cdx",
- "cfs",
- "cpio",
- "cpt",
- "dar",
- "dd",
- "dgc",
- "ear",
- "gca",
- "genozip",
- "genozip",
- "gz",
- "ha",
- "hki",
- "ice",
- "iso",
- "jar",
- "kgb",
- "lbr",
- "lha",
- "lz",
- "lz4",
- "lzh",
- "lzma",
- "lzo",
- "lzx",
- "mar",
- "pak",
- "paq6",
- "paq7",
- "paq8",
- "partimg",
- "pea",
- "phar",
- "pim",
- "pit",
- "qda",
- "rar",
- "rk",
- "rz",
- "s7z",
- "sbx",
- "sda",
- "sea",
- "sen",
- "sfark",
- "sfx",
- "shar",
- "shk",
- "sit",
- "sitx",
- "sqx",
- "sz",
- "tar",
- "tar.Z",
- "tar.bz2",
- "tar.gz",
- "tar.lz",
- "tar.xz",
- "tar.zst",
- "tbz2",
- "tgz",
- "tlz",
- "txz",
- "uc",
- "uc0",
- "uc2",
- "uca",
- "ucn",
- "ue2",
- "uha",
- "ur2",
- "war",
- "wim",
- "xar",
- "xp3",
- "xz",
- "yz1",
- "z",
- "zip",
- "zipx",
- "zoo",
- "zpaq",
- "zst",
- "zz",
- ]);
+ // Archives, scraped from https://en.wikipedia.org/wiki/List_of_archive_formats
+ add_category_extensions(Category.ARCHIVE, [
+ "7z",
+ "F",
+ "LBR",
+ "Z",
+ "a",
+ "aar",
+ "ace",
+ "afa",
+ "alz",
+ "apk",
+ "ar",
+ "arc",
+ "arc",
+ "arj",
+ "ark",
+ "b1",
+ "b6z",
+ "ba",
+ "bh",
+ "br",
+ "bz2",
+ "cab",
+ "car",
+ "cdx",
+ "cfs",
+ "cpio",
+ "cpt",
+ "dar",
+ "dd",
+ "dgc",
+ "ear",
+ "gca",
+ "genozip",
+ "genozip",
+ "gz",
+ "ha",
+ "hki",
+ "ice",
+ "iso",
+ "jar",
+ "kgb",
+ "lbr",
+ "lha",
+ "lz",
+ "lz4",
+ "lzh",
+ "lzma",
+ "lzo",
+ "lzx",
+ "mar",
+ "pak",
+ "paq6",
+ "paq7",
+ "paq8",
+ "partimg",
+ "pea",
+ "phar",
+ "pim",
+ "pit",
+ "qda",
+ "rar",
+ "rk",
+ "rz",
+ "s7z",
+ "sbx",
+ "sda",
+ "sea",
+ "sen",
+ "sfark",
+ "sfx",
+ "shar",
+ "shk",
+ "sit",
+ "sitx",
+ "sqx",
+ "sz",
+ "tar",
+ "tar.Z",
+ "tar.bz2",
+ "tar.gz",
+ "tar.lz",
+ "tar.xz",
+ "tar.zst",
+ "tbz2",
+ "tgz",
+ "tlz",
+ "txz",
+ "uc",
+ "uc0",
+ "uc2",
+ "uca",
+ "ucn",
+ "ue2",
+ "uha",
+ "ur2",
+ "war",
+ "wim",
+ "xar",
+ "xp3",
+ "xz",
+ "yz1",
+ "z",
+ "zip",
+ "zipx",
+ "zoo",
+ "zpaq",
+ "zst",
+ "zz",
+ ]);
- // Special treatment for programming languages based on GitHub's database
- for (const lang of Object.values(language_map)) {
- if (lang.type === "programming" && "extensions" in lang) {
- add_category_extensions(Category.CODE, lang.extensions);
- }
- }
+ // Special treatment for programming languages based on GitHub's database
+ for (const lang of Object.values(language_map)) {
+ if (lang.type === "programming" && "extensions" in lang) {
+ add_category_extensions(Category.CODE, lang.extensions);
+ }
+ }
- return { categories, extensions, max_extension_length };
+ return { categories, extensions, max_extension_length };
}
export class FileTypePlugin implements Plugin {
- name = "cockpit-files-filetype-plugin";
+ name = "cockpit-files-filetype-plugin";
- setup(build: PluginBuild) {
- build.onResolve({ filter: /^\.\/filetype-data$/ }, (args) => ({
- path: args.path,
- namespace: "cockpit-files-filetype-plugin",
- }));
+ setup(build: PluginBuild) {
+ build.onResolve({ filter: /^\.\/filetype-data$/ }, (args) => ({
+ path: args.path,
+ namespace: "cockpit-files-filetype-plugin",
+ }));
- build.onLoad(
- { filter: /.*/, namespace: "cockpit-files-filetype-plugin" },
- () => ({
- contents: JSON.stringify(create_filetype_data()),
- loader: "json",
- }),
- );
- }
+ build.onLoad(
+ { filter: /.*/, namespace: "cockpit-files-filetype-plugin" },
+ () => ({
+ contents: JSON.stringify(create_filetype_data()),
+ loader: "json",
+ }),
+ );
+ }
}
export const filetype_plugin = new FileTypePlugin();
diff --git a/src/header.tsx b/src/header.tsx
index 52e93b59..9dd15cae 100644
--- a/src/header.tsx
+++ b/src/header.tsx
@@ -20,24 +20,24 @@
import React, { useState } from "react";
import {
- CardHeader,
- CardTitle,
+ CardHeader,
+ CardTitle,
} from "@patternfly/react-core/dist/esm/components/Card";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import {
- MenuToggle,
- MenuToggleAction,
+ MenuToggle,
+ MenuToggleAction,
} from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput";
import {
- Select,
- SelectList,
- SelectOption,
+ Select,
+ SelectList,
+ SelectOption,
} from "@patternfly/react-core/dist/esm/components/Select";
import {
- Text,
- TextContent,
- TextVariants,
+ Text,
+ TextContent,
+ TextVariants,
} from "@patternfly/react-core/dist/esm/components/Text";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { GripVerticalIcon, ListIcon } from "@patternfly/react-icons";
@@ -50,197 +50,208 @@ import { UploadButton } from "./upload-button";
const _ = cockpit.gettext;
export enum Sort {
- az = "az",
- za = "za",
- largest_size = "largest_size",
- smallest_size = "smallest_size",
- first_modified = "first_modified",
- last_modified = "last_modified",
+ az = "az",
+ za = "za",
+ largest_size = "largest_size",
+ smallest_size = "smallest_size",
+ first_modified = "first_modified",
+ last_modified = "last_modified",
}
export function is_sort(x: unknown): x is Sort {
- return typeof x === "string" && x in Sort;
+ return typeof x === "string" && x in Sort;
}
export function as_sort(x: unknown): Sort {
- return is_sort(x) ? x : Sort.az;
+ return is_sort(x) ? x : Sort.az;
}
export const filterColumns = [
- {
- title: _("Name"),
- [SortByDirection.asc]: {
- itemId: Sort.az,
- label: _("A-Z"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.za,
- label: _("Z-A"),
- },
- },
- {
- title: _("Size"),
- [SortByDirection.asc]: {
- itemId: Sort.largest_size,
- label: _("Largest size"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.smallest_size,
- label: _("Smallest size"),
- },
- },
- {
- title: _("Modified"),
- [SortByDirection.asc]: {
- itemId: Sort.first_modified,
- label: _("First modified"),
- },
- [SortByDirection.desc]: {
- itemId: Sort.last_modified,
- label: _("Last modified"),
- },
- },
+ {
+ title: _("Name"),
+ [SortByDirection.asc]: {
+ itemId: Sort.az,
+ label: _("A-Z"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.za,
+ label: _("Z-A"),
+ },
+ },
+ {
+ title: _("Size"),
+ [SortByDirection.asc]: {
+ itemId: Sort.largest_size,
+ label: _("Largest size"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.smallest_size,
+ label: _("Smallest size"),
+ },
+ },
+ {
+ title: _("Modified"),
+ [SortByDirection.asc]: {
+ itemId: Sort.first_modified,
+ label: _("First modified"),
+ },
+ [SortByDirection.desc]: {
+ itemId: Sort.last_modified,
+ label: _("Last modified"),
+ },
+ },
] as const;
// { itemId: [index, sortdirection] }
export const filterColumnMapping = filterColumns.reduce(
- (a, v, i) => ({
- ...a,
- [v[SortByDirection.asc].itemId]: [i, SortByDirection.asc],
- [v[SortByDirection.desc].itemId]: [i, SortByDirection.desc],
- }),
- {},
+ (a, v, i) => ({
+ ...a,
+ [v[SortByDirection.asc].itemId]: [i, SortByDirection.asc],
+ [v[SortByDirection.desc].itemId]: [i, SortByDirection.desc],
+ }),
+ {},
) as Record;
export const FilesCardHeader = ({
- currentFilter,
- onFilterChange,
- isGrid,
- setIsGrid,
- sortBy,
- setSortBy,
- path,
+ currentFilter,
+ onFilterChange,
+ isGrid,
+ setIsGrid,
+ sortBy,
+ setSortBy,
+ path,
}: {
- currentFilter: string;
- onFilterChange: (
- _event: React.FormEvent,
- value: string,
- ) => void;
- isGrid: boolean;
- setIsGrid: React.Dispatch>;
- sortBy: Sort;
- setSortBy: React.Dispatch>;
- path: string[];
+ currentFilter: string;
+ onFilterChange: (
+ _event: React.FormEvent,
+ value: string,
+ ) => void;
+ isGrid: boolean;
+ setIsGrid: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
+ path: string[];
}) => {
- return (
-
-
-
-
- onFilterChange(event as React.FormEvent, "")
- }
- />
-
-
-
-
-
- );
+ return (
+
+
+
+
+ onFilterChange(
+ event as React.FormEvent,
+ "",
+ )
+ }
+ />
+
+
+
+
+
+ );
};
const ViewSelector = ({
- isGrid,
- setIsGrid,
- sortBy,
- setSortBy,
+ isGrid,
+ setIsGrid,
+ sortBy,
+ setSortBy,
}: {
- isGrid: boolean;
- setIsGrid: React.Dispatch>;
- sortBy: Sort;
- setSortBy: React.Dispatch>;
+ isGrid: boolean;
+ setIsGrid: React.Dispatch>;
+ sortBy: Sort;
+ setSortBy: React.Dispatch>;
}) => {
- const [isOpen, setIsOpen] = useState(false);
- const onToggleClick = (isOpen: boolean) => setIsOpen(!isOpen);
- const onSelect = (_ev?: React.MouseEvent, itemId?: string | number) => {
- const sort = as_sort(itemId);
- setIsOpen(false);
- setSortBy(sort);
- localStorage.setItem("files:sort", sort);
- };
+ const [isOpen, setIsOpen] = useState(false);
+ const onToggleClick = (isOpen: boolean) => setIsOpen(!isOpen);
+ const onSelect = (_ev?: React.MouseEvent, itemId?: string | number) => {
+ const sort = as_sort(itemId);
+ setIsOpen(false);
+ setSortBy(sort);
+ localStorage.setItem("files:sort", sort);
+ };
- return (
-
- );
+ return (
+
+ );
};
diff --git a/src/index.tsx b/src/index.tsx
index 724b3cd2..79f5f1b9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -36,6 +36,6 @@ import { Application } from "./app";
import "./app.scss";
document.addEventListener("DOMContentLoaded", () => {
- const root = createRoot(document.getElementById("app")!);
- root.render( );
+ const root = createRoot(document.getElementById("app")!);
+ root.render( );
});
diff --git a/src/manifest.json b/src/manifest.json
index 7573ff2e..64bb519f 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,16 +1,16 @@
{
- "requires": {
- "cockpit": "318"
- },
+ "requires": {
+ "cockpit": "318"
+ },
- "tools": {
- "index": {
- "label": "File browser",
- "keywords": [
- {
- "matches": ["files", "explorer", "filesystem"]
- }
- ]
- }
- }
+ "tools": {
+ "index": {
+ "label": "File browser",
+ "keywords": [
+ {
+ "matches": ["files", "explorer", "filesystem"]
+ }
+ ]
+ }
+ }
}
diff --git a/src/menu.tsx b/src/menu.tsx
index 1674ba8b..d3f1ccde 100644
--- a/src/menu.tsx
+++ b/src/menu.tsx
@@ -36,133 +36,137 @@ import { downloadFile } from "./download";
const _ = cockpit.gettext;
type MenuItem =
- | { type: "divider" }
- | {
- type?: never;
- title: string;
- id: string;
- onClick: () => void;
- isDisabled?: boolean;
- className?: string;
- };
+ | { type: "divider" }
+ | {
+ type?: never;
+ title: string;
+ id: string;
+ onClick: () => void;
+ isDisabled?: boolean;
+ className?: string;
+ };
export function get_menu_items(
- path: string[],
- selected: FolderFileInfo[],
- setSelected: React.Dispatch>,
- clipboard: string[],
- setClipboard: React.Dispatch>,
- cwdInfo: FileInfo | null,
- addAlert: (
- title: string,
- variant: AlertVariant,
- key: string,
- detail?: string,
- ) => void,
- dialogs: Dialogs,
+ path: string[],
+ selected: FolderFileInfo[],
+ setSelected: React.Dispatch>,
+ clipboard: string[],
+ setClipboard: React.Dispatch>,
+ cwdInfo: FileInfo | null,
+ addAlert: (
+ title: string,
+ variant: AlertVariant,
+ key: string,
+ detail?: string,
+ ) => void,
+ dialogs: Dialogs,
) {
- const currentPath = path.join("/") + "/";
- const menuItems: MenuItem[] = [];
+ const currentPath = path.join("/") + "/";
+ const menuItems: MenuItem[] = [];
- if (selected.length === 0) {
- menuItems.push(
- {
- id: "paste-item",
- title: _("Paste"),
- isDisabled: clipboard.length === 0,
- onClick: () => {
- const existingFiles = clipboard.filter(
- (sourcePath) => cwdInfo?.entries?.[basename(sourcePath)],
- );
- if (existingFiles.length > 0) {
- addAlert(
- _("Pasting failed"),
- AlertVariant.danger,
- "paste-error",
- cockpit.format(
- _('"$0" exists, not overwriting with paste.'),
- existingFiles.map(basename).join(", "),
- ),
- );
- return;
- }
- cockpit
- .spawn(["cp", "-R", ...clipboard, currentPath])
- .catch((err) =>
- addAlert(
- err.message,
- AlertVariant.danger,
- `${new Date().getTime()}`,
- ),
- );
- },
- },
- { type: "divider" },
- {
- id: "create-item",
- title: _("Create directory"),
- onClick: () => show_create_directory_dialog(dialogs, currentPath),
- },
- { type: "divider" },
- {
- id: "edit-permissions",
- title: _("Edit permissions"),
- onClick: () => edit_permissions(dialogs, null, path),
- },
- );
- } else if (selected.length === 1) {
- menuItems.push(
- {
- id: "copy-item",
- title: _("Copy"),
- onClick: () => setClipboard([currentPath + selected[0].name]),
- },
- { type: "divider" },
- {
- id: "edit-permissions",
- title: _("Edit permissions"),
- onClick: () => edit_permissions(dialogs, selected[0], path),
- },
- {
- id: "rename-item",
- title: _("Rename"),
- onClick: () => show_rename_dialog(dialogs, path, selected[0]),
- },
- { type: "divider" },
- {
- id: "delete-item",
- title: _("Delete"),
- className: "pf-m-danger",
- onClick: () =>
- confirm_delete(dialogs, currentPath, selected, setSelected),
- },
- );
- if (selected[0].type === "reg")
- menuItems.push(
- { type: "divider" },
- {
- id: "download-item",
- title: _("Download"),
- onClick: () => downloadFile(currentPath, selected[0]),
- },
- );
- } else if (selected.length > 1) {
- menuItems.push(
- {
- id: "copy-item",
- title: _("Copy"),
- onClick: () =>
- setClipboard(selected.map((s) => path.join("/") + "/" + s.name)),
- },
- {
- id: "delete-item",
- title: _("Delete"),
- className: "pf-m-danger",
- onClick: () =>
- confirm_delete(dialogs, currentPath, selected, setSelected),
- },
- );
- }
+ if (selected.length === 0) {
+ menuItems.push(
+ {
+ id: "paste-item",
+ title: _("Paste"),
+ isDisabled: clipboard.length === 0,
+ onClick: () => {
+ const existingFiles = clipboard.filter(
+ (sourcePath) =>
+ cwdInfo?.entries?.[basename(sourcePath)],
+ );
+ if (existingFiles.length > 0) {
+ addAlert(
+ _("Pasting failed"),
+ AlertVariant.danger,
+ "paste-error",
+ cockpit.format(
+ _('"$0" exists, not overwriting with paste.'),
+ existingFiles.map(basename).join(", "),
+ ),
+ );
+ return;
+ }
+ cockpit
+ .spawn(["cp", "-R", ...clipboard, currentPath])
+ .catch((err) =>
+ addAlert(
+ err.message,
+ AlertVariant.danger,
+ `${new Date().getTime()}`,
+ ),
+ );
+ },
+ },
+ { type: "divider" },
+ {
+ id: "create-item",
+ title: _("Create directory"),
+ onClick: () =>
+ show_create_directory_dialog(dialogs, currentPath),
+ },
+ { type: "divider" },
+ {
+ id: "edit-permissions",
+ title: _("Edit permissions"),
+ onClick: () => edit_permissions(dialogs, null, path),
+ },
+ );
+ } else if (selected.length === 1) {
+ menuItems.push(
+ {
+ id: "copy-item",
+ title: _("Copy"),
+ onClick: () => setClipboard([currentPath + selected[0].name]),
+ },
+ { type: "divider" },
+ {
+ id: "edit-permissions",
+ title: _("Edit permissions"),
+ onClick: () => edit_permissions(dialogs, selected[0], path),
+ },
+ {
+ id: "rename-item",
+ title: _("Rename"),
+ onClick: () => show_rename_dialog(dialogs, path, selected[0]),
+ },
+ { type: "divider" },
+ {
+ id: "delete-item",
+ title: _("Delete"),
+ className: "pf-m-danger",
+ onClick: () =>
+ confirm_delete(dialogs, currentPath, selected, setSelected),
+ },
+ );
+ if (selected[0].type === "reg")
+ menuItems.push(
+ { type: "divider" },
+ {
+ id: "download-item",
+ title: _("Download"),
+ onClick: () => downloadFile(currentPath, selected[0]),
+ },
+ );
+ } else if (selected.length > 1) {
+ menuItems.push(
+ {
+ id: "copy-item",
+ title: _("Copy"),
+ onClick: () =>
+ setClipboard(
+ selected.map((s) => path.join("/") + "/" + s.name),
+ ),
+ },
+ {
+ id: "delete-item",
+ title: _("Delete"),
+ className: "pf-m-danger",
+ onClick: () =>
+ confirm_delete(dialogs, currentPath, selected, setSelected),
+ },
+ );
+ }
- return menuItems;
+ return menuItems;
}
diff --git a/src/ownership.tsx b/src/ownership.tsx
index 9bbd9044..1a0280d3 100644
--- a/src/ownership.tsx
+++ b/src/ownership.tsx
@@ -7,42 +7,42 @@ import type { FileInfo } from "cockpit/fsinfo";
// case this function should not be used). This is very much a heuristic, and
// might change in the future.
export function get_owner_candidates(user: cockpit.UserInfo, info: FileInfo) {
- // In case the parent directory is setgid, we always override the group we
- // create as, mirroring the usual POSIX behaviour. There are other cases
- // where the "BSD group semantics" come into play (like mount options) but
- // we don't currently support those. We might in the future, though...
- const setgid =
- info.group !== undefined && (info.mode || 0) & 0o2000
- ? `${info.group}`
- : null;
+ // In case the parent directory is setgid, we always override the group we
+ // create as, mirroring the usual POSIX behaviour. There are other cases
+ // where the "BSD group semantics" come into play (like mount options) but
+ // we don't currently support those. We might in the future, though...
+ const setgid =
+ info.group !== undefined && (info.mode || 0) & 0o2000
+ ? `${info.group}`
+ : null;
- // Set() is ordered: we insert options in the order of preference.
- const candidates = new Set();
+ // Set() is ordered: we insert options in the order of preference.
+ const candidates = new Set();
- // Most preferred option: create with the ownership of the parent
- // directory. Don't offer this if:
- // - the directory is a world-writable sticky (like /tmp)
- // - we don't know the ownership information of the parent
- if (!info.mode || (info.mode & 0o1222) !== 0o1222) {
- if (info.user !== undefined && info.group !== undefined) {
- candidates.add(`${info.user}:${info.group}`);
- }
- }
+ // Most preferred option: create with the ownership of the parent
+ // directory. Don't offer this if:
+ // - the directory is a world-writable sticky (like /tmp)
+ // - we don't know the ownership information of the parent
+ if (!info.mode || (info.mode & 0o1222) !== 0o1222) {
+ if (info.user !== undefined && info.group !== undefined) {
+ candidates.add(`${info.user}:${info.group}`);
+ }
+ }
- // If we're authenticated as the superuser, we can do root:root as well.
- candidates.add(`root:${setgid || "root"}`);
+ // If we're authenticated as the superuser, we can do root:root as well.
+ candidates.add(`root:${setgid || "root"}`);
- // The last option is always available: create as the normal user. In case
- // of something inside of the user's home directory, this was probably the
- // first option as well...
- // The first group from `cockpit.user()` is guaranteed to be the users default group
- if (user.groups.length >= 1) {
- candidates.add(
- `${user.name || user.id}:${setgid || user.groups[0] || user.gid}`,
- );
- } else {
- candidates.add(`${user.name || user.id}:${setgid || user.gid}`);
- }
+ // The last option is always available: create as the normal user. In case
+ // of something inside of the user's home directory, this was probably the
+ // first option as well...
+ // The first group from `cockpit.user()` is guaranteed to be the users default group
+ if (user.groups.length >= 1) {
+ candidates.add(
+ `${user.name || user.id}:${setgid || user.groups[0] || user.gid}`,
+ );
+ } else {
+ candidates.add(`${user.name || user.id}:${setgid || user.gid}`);
+ }
- return [...candidates];
+ return [...candidates];
}
diff --git a/src/sidebar.tsx b/src/sidebar.tsx
index 1ad2f66c..2e0c8bf3 100644
--- a/src/sidebar.tsx
+++ b/src/sidebar.tsx
@@ -21,23 +21,23 @@ import React, { useState, useEffect } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
- Card,
- CardBody,
- CardHeader,
- CardTitle,
+ Card,
+ CardBody,
+ CardHeader,
+ CardTitle,
} from "@patternfly/react-core/dist/esm/components/Card";
import {
- DescriptionList,
- DescriptionListDescription,
- DescriptionListGroup,
- DescriptionListTerm,
+ DescriptionList,
+ DescriptionListDescription,
+ DescriptionListGroup,
+ DescriptionListTerm,
} from "@patternfly/react-core/dist/esm/components/DescriptionList";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
import {
- Text,
- TextContent,
- TextVariants,
+ Text,
+ TextContent,
+ TextVariants,
} from "@patternfly/react-core/dist/esm/components/Text";
import cockpit from "cockpit";
@@ -53,185 +53,197 @@ import { get_menu_items } from "./menu";
const _ = cockpit.gettext;
function getDescriptionListItems(selected: FolderFileInfo) {
- return [
- {
- id: "description-list-last-modified",
- label: _("Last modified"),
- value:
- "mtime" in selected
- ? timeformat.dateTime(selected.mtime * 1000)
- : _("unknown"),
- },
- {
- id: "description-list-owner",
- label: _("Owner"),
- value: "user" in selected ? selected.user : _("unknown"),
- },
- {
- id: "description-list-group",
- label: _("Group"),
- value: "group" in selected ? selected.group : _("unknown"),
- },
- ...(selected.type === "reg"
- ? [
- {
- id: "description-list-size",
- label: _("Size"),
- value: cockpit.format_bytes(selected.size) || _("unknown"),
- },
- ]
- : []),
- ...("mode" in selected
- ? [
- {
- id: "description-list-owner-permissions",
- label: _("Owner permissions"),
- value: get_permissions(selected.mode >> 6),
- },
- {
- id: "description-list-group-permissions",
- label: _("Group permissions"),
- value: get_permissions(selected.mode >> 3),
- },
- {
- id: "description-list-other-permissions",
- label: _("Other permissions"),
- value: get_permissions(selected.mode >> 0),
- },
- ]
- : []),
- ];
+ return [
+ {
+ id: "description-list-last-modified",
+ label: _("Last modified"),
+ value:
+ "mtime" in selected
+ ? timeformat.dateTime(selected.mtime * 1000)
+ : _("unknown"),
+ },
+ {
+ id: "description-list-owner",
+ label: _("Owner"),
+ value: "user" in selected ? selected.user : _("unknown"),
+ },
+ {
+ id: "description-list-group",
+ label: _("Group"),
+ value: "group" in selected ? selected.group : _("unknown"),
+ },
+ ...(selected.type === "reg"
+ ? [
+ {
+ id: "description-list-size",
+ label: _("Size"),
+ value:
+ cockpit.format_bytes(selected.size) || _("unknown"),
+ },
+ ]
+ : []),
+ ...("mode" in selected
+ ? [
+ {
+ id: "description-list-owner-permissions",
+ label: _("Owner permissions"),
+ value: get_permissions(selected.mode >> 6),
+ },
+ {
+ id: "description-list-group-permissions",
+ label: _("Group permissions"),
+ value: get_permissions(selected.mode >> 3),
+ },
+ {
+ id: "description-list-other-permissions",
+ label: _("Other permissions"),
+ value: get_permissions(selected.mode >> 0),
+ },
+ ]
+ : []),
+ ];
}
export const SidebarPanelDetails = ({
- files,
- path,
- selected,
- setSelected,
- showHidden,
- clipboard,
- setClipboard,
+ files,
+ path,
+ selected,
+ setSelected,
+ showHidden,
+ clipboard,
+ setClipboard,
}: {
- files: FolderFileInfo[];
- path: string[];
- selected: FolderFileInfo[];
- setSelected: React.Dispatch>;
- showHidden: boolean;
- clipboard: string[];
- setClipboard: React.Dispatch>;
+ files: FolderFileInfo[];
+ path: string[];
+ selected: FolderFileInfo[];
+ setSelected: React.Dispatch>;
+ showHidden: boolean;
+ clipboard: string[];
+ setClipboard: React.Dispatch>;
}) => {
- const [info, setInfo] = useState(null);
- const { addAlert, cwdInfo } = useFilesContext();
+ const [info, setInfo] = useState(null);
+ const { addAlert, cwdInfo } = useFilesContext();
- useEffect(() => {
- if (selected.length === 1) {
- const filePath = path.join("/") + "/" + selected[0]?.name;
+ useEffect(() => {
+ if (selected.length === 1) {
+ const filePath = path.join("/") + "/" + selected[0]?.name;
- cockpit
- .spawn(["file", "--brief", filePath], {
- superuser: "try",
- err: "message",
- })
- .then((res) => setInfo(res?.trim()))
- .catch((error) =>
- console.warn(
- `Failed to run file --brief on ${filePath}: ${error.toString()}`,
- ),
- );
- }
- }, [path, selected]);
+ cockpit
+ .spawn(["file", "--brief", filePath], {
+ superuser: "try",
+ err: "message",
+ })
+ .then((res) => setInfo(res?.trim()))
+ .catch((error) =>
+ console.warn(
+ `Failed to run file --brief on ${filePath}: ${error.toString()}`,
+ ),
+ );
+ }
+ }, [path, selected]);
- const dialogs = useDialogs();
- const directory_name = path[path.length - 1];
- const hidden_count = files.filter((file) => file.name.startsWith(".")).length;
- let shown_items = cockpit.format(
- cockpit.ngettext("$0 item", "$0 items", files.length),
- files.length,
- );
- if (!showHidden)
- shown_items +=
- " " +
- cockpit.format(
- cockpit.ngettext("($0 hidden)", "($0 hidden)", hidden_count),
- hidden_count,
- );
+ const dialogs = useDialogs();
+ const directory_name = path[path.length - 1];
+ const hidden_count = files.filter((file) =>
+ file.name.startsWith("."),
+ ).length;
+ let shown_items = cockpit.format(
+ cockpit.ngettext("$0 item", "$0 items", files.length),
+ files.length,
+ );
+ if (!showHidden)
+ shown_items +=
+ " " +
+ cockpit.format(
+ cockpit.ngettext("($0 hidden)", "($0 hidden)", hidden_count),
+ hidden_count,
+ );
- const menuItems = get_menu_items(
- path,
- selected,
- setSelected,
- clipboard,
- setClipboard,
- cwdInfo,
- addAlert,
- dialogs,
- ).map((option, i) => {
- if (option.type === "divider") return ;
- return (
-
- {option.title}
-
- );
- });
+ const menuItems = get_menu_items(
+ path,
+ selected,
+ setSelected,
+ clipboard,
+ setClipboard,
+ cwdInfo,
+ addAlert,
+ dialogs,
+ ).map((option, i) => {
+ if (option.type === "divider") return ;
+ return (
+
+ {option.title}
+
+ );
+ });
- return (
-
-
-
-
-
- {selected.length === 1 && (
-
-
- {
- edit_permissions(dialogs, selected[0], path);
- }}
- >
- {_("Edit permissions")}
-
-
- )}
-
- );
+ return (
+
+
+
+
+
+ {selected.length === 1 && (
+
+
+ {
+ edit_permissions(dialogs, selected[0], path);
+ }}
+ >
+ {_("Edit permissions")}
+
+
+ )}
+
+ );
};
diff --git a/src/upload-button.tsx b/src/upload-button.tsx
index ab736030..b74618cb 100644
--- a/src/upload-button.tsx
+++ b/src/upload-button.tsx
@@ -24,12 +24,12 @@ import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import {
- Modal,
- ModalVariant,
+ Modal,
+ ModalVariant,
} from "@patternfly/react-core/dist/esm/components/Modal";
import {
- Popover,
- PopoverPosition,
+ Popover,
+ PopoverPosition,
} from "@patternfly/react-core/dist/esm/components/Popover";
import { Progress } from "@patternfly/react-core/dist/esm/components/Progress";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
@@ -49,347 +49,369 @@ import "./upload-button.scss";
const _ = cockpit.gettext;
interface ConflictResult {
- replace?: true;
- skip?: true;
- applyToAll: boolean;
+ replace?: true;
+ skip?: true;
+ applyToAll: boolean;
}
const FileConflictDialog = ({
- path,
- file,
- uploadFile,
- isMultiUpload,
- dialogResult,
+ path,
+ file,
+ uploadFile,
+ isMultiUpload,
+ dialogResult,
}: {
- path: string[];
- file: FileInfo;
- uploadFile: File;
- isMultiUpload: boolean;
- dialogResult: DialogResult;
+ path: string[];
+ file: FileInfo;
+ uploadFile: File;
+ isMultiUpload: boolean;
+ dialogResult: DialogResult;
}) => {
- const [applyToAll, setApplyToAll] = React.useState(false);
-
- const handleReplace = () => {
- dialogResult.resolve({ replace: true, applyToAll });
- };
-
- const handleSkip = () => {
- dialogResult.resolve({ skip: true, applyToAll });
- };
-
- const handleCancel = () => {
- dialogResult.reject(new Error("cancelled"));
- };
-
- return (
- {uploadFile.name})}
- titleIconVariant="warning"
- variant={ModalVariant.medium}
- onClose={handleCancel}
- isOpen
- footer={
- <>
-
- {_("Replace")}
-
- {isMultiUpload && (
-
- {_("Keep original")}
-
- )}
-
- {_("Cancel")}
-
- >
- }
- >
-
- {cockpit.format(
- _(
- 'A file with the same name already exists in "$0". Replacing it will overwrite its content.',
- ),
- path.join("/"),
- )}
-
-
-
- {_("New file")}
- {cockpit.format_bytes(uploadFile.size)}
-
- {timeformat.dateTime(uploadFile.lastModified)}
-
-
-
- {_("Original file on server")}
- {cockpit.format_bytes(file.size)}
- {file.mtime && (
-
- {timeformat.dateTime(file.mtime * 1000)}
-
- )}
-
-
- {isMultiUpload && (
- setApplyToAll(!applyToAll)}
- />
- )}
-
- );
+ const [applyToAll, setApplyToAll] = React.useState(false);
+
+ const handleReplace = () => {
+ dialogResult.resolve({ replace: true, applyToAll });
+ };
+
+ const handleSkip = () => {
+ dialogResult.resolve({ skip: true, applyToAll });
+ };
+
+ const handleCancel = () => {
+ dialogResult.reject(new Error("cancelled"));
+ };
+
+ return (
+ {uploadFile.name},
+ )}
+ titleIconVariant="warning"
+ variant={ModalVariant.medium}
+ onClose={handleCancel}
+ isOpen
+ footer={
+ <>
+
+ {_("Replace")}
+
+ {isMultiUpload && (
+
+ {_("Keep original")}
+
+ )}
+
+ {_("Cancel")}
+
+ >
+ }
+ >
+
+ {cockpit.format(
+ _(
+ 'A file with the same name already exists in "$0". Replacing it will overwrite its content.',
+ ),
+ path.join("/"),
+ )}
+
+
+
+ {_("New file")}
+ {cockpit.format_bytes(uploadFile.size)}
+
+ {timeformat.dateTime(uploadFile.lastModified)}
+
+
+
+ {_("Original file on server")}
+ {cockpit.format_bytes(file.size)}
+ {file.mtime && (
+
+ {timeformat.dateTime(file.mtime * 1000)}
+
+ )}
+
+
+ {isMultiUpload && (
+ setApplyToAll(!applyToAll)}
+ />
+ )}
+
+ );
};
export const UploadButton = ({
- path,
+ path,
}: {
- path: string[];
+ path: string[];
}) => {
- const ref = useRef(null);
- const { addAlert, cwdInfo } = useFilesContext();
- const dialogs = useDialogs();
- const [showPopover, setPopover] = React.useState(false);
- const [uploadedFiles, setUploadedFiles] = useState<{
- [name: string]: { file: File; progress: number; cancel: () => void };
- }>({});
-
- const handleClick = () => {
- if (ref.current) {
- ref.current.click();
- }
- };
-
- // Show a confirmation before closing the tab while uploading
- const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
- event.preventDefault();
-
- // Included for legacy support, e.g. Chrome/Edge < 119
- event.returnValue = true;
- };
-
- const onUpload = async (event: React.ChangeEvent) => {
- cockpit.assert(event.target.files, "not an ?");
- cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
- let next_progress = 0;
- const toUploadFiles = [];
-
- const resetInput = () => {
- // Reset input field in the case a download was cancelled and has to be re-uploaded
- // https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
- event.target.value = "";
- };
-
- let resolution;
- let replaceAll = false;
- let skipAll = false;
- for (let i = 0; i < event.target.files.length; i++) {
- const uploadFile = event.target.files[i];
- const file = cwdInfo?.entries[uploadFile.name];
-
- if (replaceAll) toUploadFiles.push(uploadFile);
- else if (file && skipAll) {
- continue;
- } else if (file) {
- try {
- resolution = await dialogs.run(FileConflictDialog, {
- path,
- file,
- uploadFile,
- isMultiUpload: event.target.files.length > 1,
- });
- } catch (exc) {
- resetInput();
- return;
- }
-
- if (resolution.skip) {
- if (resolution.applyToAll) skipAll = true;
- continue;
- }
-
- if (resolution.applyToAll && resolution.replace) replaceAll = true;
-
- toUploadFiles.push(uploadFile);
- } else {
- toUploadFiles.push(uploadFile);
- }
- }
-
- if (toUploadFiles.length === 0) {
- resetInput();
- return;
- }
-
- window.addEventListener("beforeunload", beforeUnloadHandler);
-
- const cancelledUploads = [];
- await Promise.allSettled(
- toUploadFiles.map(async (file: File) => {
- const tmp_path = path.slice();
- tmp_path.push(file.name);
- const destination = tmp_path.join("/");
- const abort = new AbortController();
-
- setUploadedFiles((oldFiles) => {
- return {
- [file.name]: { file, progress: 0, cancel: () => abort.abort() },
- ...oldFiles,
- };
- });
-
- try {
- await upload(
- destination,
- file,
- (progress) => {
- const now = performance.now();
- if (now < next_progress) return;
- next_progress = now + 200; // only rerender every 200ms
- setUploadedFiles((oldFiles) => {
- const oldFile = oldFiles[file.name];
- return {
- ...oldFiles,
- [file.name]: { ...oldFile, progress },
- };
- });
- },
- abort.signal,
- );
- // TODO: pass { superuser: try } depending on directory owner
- } catch (exc) {
- cockpit.assert(exc instanceof Error, "Unknown exception type");
- if (exc instanceof DOMException && exc.name === "AbortError") {
- addAlert(
- _("Cancelled"),
- AlertVariant.warning,
- "upload",
- cockpit.format(_("Cancelled upload of $0"), file.name),
- );
- } else {
- addAlert(
- _("Upload error"),
- AlertVariant.danger,
- "upload-error",
- exc.toString(),
- );
- }
- cancelledUploads.push(file);
- } finally {
- setUploadedFiles((oldFiles) => {
- const copy = { ...oldFiles };
- delete copy[file.name];
- return copy;
- });
- }
- }),
- );
-
- resetInput();
- window.removeEventListener("beforeunload", beforeUnloadHandler);
-
- // If all uploads are cancelled, don't show an alert
- if (cancelledUploads.length !== toUploadFiles.length) {
- addAlert(
- _("Upload complete"),
- AlertVariant.success,
- "upload-success",
- _("Successfully uploaded file(s)"),
- );
- }
- };
-
- const isUploading = Object.keys(uploadedFiles).length !== 0;
- let popover;
-
- if (isUploading) {
- let totalSize = 0;
- let totalSent = 0;
-
- Object.keys(uploadedFiles).forEach((key) => {
- const uploadedFile = uploadedFiles[key];
- totalSize += uploadedFile.file.size;
- totalSent += uploadedFile.progress;
- });
-
- const overallProgress = ((totalSent / totalSize) * 100).toFixed(2);
-
- popover = (
- {_("Uploads")}}
- bodyContent={Object.keys(uploadedFiles).map((key, index) => {
- const file = uploadedFiles[key];
- return (
-
-
-
-
- }
- className={`cancel-button-${index} cancel-button`}
- onClick={file.cancel}
- aria-label={cockpit.format(
- _("Cancel upload of $0"),
- file.file.name,
- )}
- />
-
-
- );
- })}
- isVisible={showPopover}
- shouldClose={() => setPopover(false)}
- >
- setPopover(true)}
- className="progress-wrapper"
- variant="plain"
- icon={
-
- }
- />
-
- );
- }
-
- return (
- <>
- {popover}
-
- {_("Upload")}
-
-
- >
- );
+ const ref = useRef(null);
+ const { addAlert, cwdInfo } = useFilesContext();
+ const dialogs = useDialogs();
+ const [showPopover, setPopover] = React.useState(false);
+ const [uploadedFiles, setUploadedFiles] = useState<{
+ [name: string]: { file: File; progress: number; cancel: () => void };
+ }>({});
+
+ const handleClick = () => {
+ if (ref.current) {
+ ref.current.click();
+ }
+ };
+
+ // Show a confirmation before closing the tab while uploading
+ const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+
+ // Included for legacy support, e.g. Chrome/Edge < 119
+ event.returnValue = true;
+ };
+
+ const onUpload = async (event: React.ChangeEvent) => {
+ cockpit.assert(event.target.files, "not an ?");
+ cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
+ let next_progress = 0;
+ const toUploadFiles = [];
+
+ const resetInput = () => {
+ // Reset input field in the case a download was cancelled and has to be re-uploaded
+ // https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
+ event.target.value = "";
+ };
+
+ let resolution;
+ let replaceAll = false;
+ let skipAll = false;
+ for (let i = 0; i < event.target.files.length; i++) {
+ const uploadFile = event.target.files[i];
+ const file = cwdInfo?.entries[uploadFile.name];
+
+ if (replaceAll) toUploadFiles.push(uploadFile);
+ else if (file && skipAll) {
+ continue;
+ } else if (file) {
+ try {
+ resolution = await dialogs.run(FileConflictDialog, {
+ path,
+ file,
+ uploadFile,
+ isMultiUpload: event.target.files.length > 1,
+ });
+ } catch (exc) {
+ resetInput();
+ return;
+ }
+
+ if (resolution.skip) {
+ if (resolution.applyToAll) skipAll = true;
+ continue;
+ }
+
+ if (resolution.applyToAll && resolution.replace)
+ replaceAll = true;
+
+ toUploadFiles.push(uploadFile);
+ } else {
+ toUploadFiles.push(uploadFile);
+ }
+ }
+
+ if (toUploadFiles.length === 0) {
+ resetInput();
+ return;
+ }
+
+ window.addEventListener("beforeunload", beforeUnloadHandler);
+
+ const cancelledUploads = [];
+ await Promise.allSettled(
+ toUploadFiles.map(async (file: File) => {
+ const tmp_path = path.slice();
+ tmp_path.push(file.name);
+ const destination = tmp_path.join("/");
+ const abort = new AbortController();
+
+ setUploadedFiles((oldFiles) => {
+ return {
+ [file.name]: {
+ file,
+ progress: 0,
+ cancel: () => abort.abort(),
+ },
+ ...oldFiles,
+ };
+ });
+
+ try {
+ await upload(
+ destination,
+ file,
+ (progress) => {
+ const now = performance.now();
+ if (now < next_progress) return;
+ next_progress = now + 200; // only rerender every 200ms
+ setUploadedFiles((oldFiles) => {
+ const oldFile = oldFiles[file.name];
+ return {
+ ...oldFiles,
+ [file.name]: { ...oldFile, progress },
+ };
+ });
+ },
+ abort.signal,
+ );
+ // TODO: pass { superuser: try } depending on directory owner
+ } catch (exc) {
+ cockpit.assert(
+ exc instanceof Error,
+ "Unknown exception type",
+ );
+ if (
+ exc instanceof DOMException &&
+ exc.name === "AbortError"
+ ) {
+ addAlert(
+ _("Cancelled"),
+ AlertVariant.warning,
+ "upload",
+ cockpit.format(
+ _("Cancelled upload of $0"),
+ file.name,
+ ),
+ );
+ } else {
+ addAlert(
+ _("Upload error"),
+ AlertVariant.danger,
+ "upload-error",
+ exc.toString(),
+ );
+ }
+ cancelledUploads.push(file);
+ } finally {
+ setUploadedFiles((oldFiles) => {
+ const copy = { ...oldFiles };
+ delete copy[file.name];
+ return copy;
+ });
+ }
+ }),
+ );
+
+ resetInput();
+ window.removeEventListener("beforeunload", beforeUnloadHandler);
+
+ // If all uploads are cancelled, don't show an alert
+ if (cancelledUploads.length !== toUploadFiles.length) {
+ addAlert(
+ _("Upload complete"),
+ AlertVariant.success,
+ "upload-success",
+ _("Successfully uploaded file(s)"),
+ );
+ }
+ };
+
+ const isUploading = Object.keys(uploadedFiles).length !== 0;
+ let popover;
+
+ if (isUploading) {
+ let totalSize = 0;
+ let totalSent = 0;
+
+ Object.keys(uploadedFiles).forEach((key) => {
+ const uploadedFile = uploadedFiles[key];
+ totalSize += uploadedFile.file.size;
+ totalSent += uploadedFile.progress;
+ });
+
+ const overallProgress = ((totalSent / totalSize) * 100).toFixed(2);
+
+ popover = (
+ {_("Uploads")}}
+ bodyContent={Object.keys(uploadedFiles).map((key, index) => {
+ const file = uploadedFiles[key];
+ return (
+
+
+
+
+ }
+ className={`cancel-button-${index} cancel-button`}
+ onClick={file.cancel}
+ aria-label={cockpit.format(
+ _("Cancel upload of $0"),
+ file.file.name,
+ )}
+ />
+
+
+ );
+ })}
+ isVisible={showPopover}
+ shouldClose={() => setPopover(false)}
+ >
+ setPopover(true)}
+ className="progress-wrapper"
+ variant="plain"
+ icon={
+
+ }
+ />
+
+ );
+ }
+
+ return (
+ <>
+ {popover}
+
+ {_("Upload")}
+
+
+ >
+ );
};
From 6958147bee39de1ff74e44c0bdc3bc1c70b868ca Mon Sep 17 00:00:00 2001
From: Luna
Date: Wed, 24 Jul 2024 16:47:24 +0530
Subject: [PATCH 5/5] skip line for xgetext
---
node_modules | 2 +-
src/upload-button.tsx | 6 ++----
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/node_modules b/node_modules
index 370c98aa..e7896d88 160000
--- a/node_modules
+++ b/node_modules
@@ -1 +1 @@
-Subproject commit 370c98aa1c9daf0cf5da3d36ac15a2aa69a21e4f
+Subproject commit e7896d8800c69f9569a0a6cedf1b5eee5f88e6c4
diff --git a/src/upload-button.tsx b/src/upload-button.tsx
index b74618cb..b849c528 100644
--- a/src/upload-button.tsx
+++ b/src/upload-button.tsx
@@ -110,10 +110,8 @@ const FileConflictDialog = ({
}
>
- {cockpit.format(
- _(
- 'A file with the same name already exists in "$0". Replacing it will overwrite its content.',
- ),
+ {cockpit.format(
+ _("A file with the same name already exists in \"$0\". Replacing it will overwrite its content."), // biome-ignore format: don't break gettext
path.join("/"),
)}