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={ - <> - - - - } - > - {errorMessage && - } - - ); + return ( + dialogResult.resolve()} + footer={ + <> + + + + } + > + {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={ - <> - - - - } - > - - {errorMessage !== undefined && - } -
{ - createDirectory(); - e.preventDefault(); - return false; - }} - > - - { - setNameError(check_name(val)); - setErrorMessage(undefined); - setName(val); - }} - id="create-directory-input" autoFocus // eslint-disable-line jsx-a11y/no-autofocus - /> - - - {candidates.length > 0 && - - setOwner(val)} - > - {candidates.map(owner => - )} - - } -
-
-
- ); + return ( + dialogResult.resolve()} + variant={ModalVariant.small} + footer={ + <> + + + + } + > + + {errorMessage !== undefined && ( + + )} +
{ + createDirectory(); + e.preventDefault(); + return false; + }} + > + + { + setNameError(check_name(val)); + setErrorMessage(undefined); + setName(val); + }} + id="create-directory-input" + autoFocus // eslint-disable-line jsx-a11y/no-autofocus + /> + + + {candidates.length > 0 && ( + + setOwner(val)} + > + {candidates.map((owner) => ( + + ))} + + + )} +
+
+
+ ); }; -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 ( - - - - - } - > - - {errorMessage !== undefined && - } -
- {superuser.allowed && accounts && groups && - - - changeOwner(val)} id="edit-permissions-owner" - value={owner} - > - {accounts?.sort(sortByName).map(a => { - return ( - - ); - })} - - - - setGroup(val)} id="edit-permissions-group" - value={group} - > - {groups?.sort(sortByName).map(g => { - return ( - - ); - })} - - - } - - - > 6) & 7} - onChange={(_, val) => { setMode((mode & 0o077) | (val << 6)) }} - id="edit-permissions-owner-access" - > - {permissions_options()} - - - - > 3) & 7} - onChange={(_, val) => { setMode((mode & 0o707) | (val << 3)) }} - id="edit-permissions-group-access" - > - {permissions_options()} - - - - { setMode((mode & 0o770) | val) }} - id="edit-permissions-other-access" - > - {permissions_options()} - - - -
-
-
- ); + 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 ( + + + + + } + > + + {errorMessage !== undefined && ( + + )} +
+ {superuser.allowed && accounts && groups && ( + + + changeOwner(val)} + id="edit-permissions-owner" + value={owner} + > + {accounts?.sort(sortByName).map((a) => { + return ( + + ); + })} + + + + setGroup(val)} + id="edit-permissions-group" + value={group} + > + {groups?.sort(sortByName).map((g) => { + return ( + + ); + })} + + + + )} + + + > 6) & 7} + onChange={(_, val) => { + setMode((mode & 0o077) | (val << 6)); + }} + id="edit-permissions-owner-access" + > + {permissions_options()} + + + + > 3) & 7} + onChange={(_, val) => { + setMode((mode & 0o707) | (val << 3)); + }} + id="edit-permissions-group-access" + > + {permissions_options()} + + + + { + setMode((mode & 0o770) | val); + }} + id="edit-permissions-other-access" + > + {permissions_options()} + + + +
+
+
+ ); }; 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 = ( - <> - - {overrideFileName && - } - - - ); - - const label = selected.type !== "dir" ? _("New filename") : _("New name"); - - return ( - {selected.name})} - variant={ModalVariant.small} - isOpen - onClose={() => dialogResult.resolve()} - footer={footer} - > - - {errorMessage !== undefined && - } -
{ - e.preventDefault(); - if (name !== selected.name) - renameItem(); - else { - setNameError(checkName(name, cwdInfo?.entries || {}, selected)); - } - return false; - }} - > - - { - setNameError(checkName(val, cwdInfo?.entries || {}, selected)); - setOverrideFileName(checkCanOverride(val, cwdInfo?.entries || {}, selected)); - setErrorMessage(undefined); - setName(val); - }} - id="rename-item-input" - /> - - -
-
-
- ); + 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 = ( + <> + + {overrideFileName && ( + + )} + + + ); + + const label = selected.type !== "dir" ? _("New filename") : _("New name"); + + return ( + {selected.name})} + variant={ModalVariant.small} + isOpen + onClose={() => dialogResult.resolve()} + footer={footer} + > + + {errorMessage !== undefined && ( + + )} +
{ + e.preventDefault(); + if (name !== selected.name) renameItem(); + else { + setNameError(checkName(name, cwdInfo?.entries || {}, selected)); + } + return false; + }} + > + + { + setNameError(checkName(val, cwdInfo?.entries || {}, selected)); + setOverrideFileName( + checkCanOverride(val, cwdInfo?.entries || {}, selected), + ); + setErrorMessage(undefined); + setName(val); + }} + id="rename-item-input" + /> + + +
+
+
+ ); }; -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 && - - - {dir !== "" &&

/

} - - ); - })} - {editMode && newPath !== null && - - event.target.select()} - onKeyDown={handleInputKey} - onChange={(_event, value) => setNewPath(value)} - /> - } - - {editMode && - <> - + {dir !== "" && ( +

+ / +

+ )} + + ); + })} + {editMode && newPath !== null && ( + + event.target.select()} + onKeyDown={handleInputKey} + onChange={(_event, value) => setNewPath(value)} + /> + + )} + + {editMode && ( + <> + - } - - ); + return ( + + + + + + {selected.length === 1 ? selected[0].name : directory_name} + + {selected.length === 0 && ( + {shown_items} + )} + {selected.length > 1 && ( + + {cockpit.format("$0 items selected", selected.length)} + + )} + {selected.length === 1 && ( + {info} + )} + + + + + {selected.length === 1 && ( + + + {getDescriptionListItems(selected[0]).map((item) => ( + + {item.label} + + {item.value} + + + ))} + + + + )} + + ); }; 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={ - <> - - {isMultiUpload && - } - - - } - > -

- {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={ + <> + + {isMultiUpload && ( + + )} + + + } + > +

+ {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 ( - - - - - - - - ); + 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 ( + + + + + + + + ); }; 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={ - <> - - - - } - > - {errorMessage && ( - - )} - - ); + return ( + dialogResult.resolve()} + footer={ + <> + + + + } + > + {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={ - <> - - - - } - > - - {errorMessage !== undefined && ( - - )} -
{ - createDirectory(); - e.preventDefault(); - return false; - }} - > - - { - setNameError(check_name(val)); - setErrorMessage(undefined); - setName(val); - }} - id="create-directory-input" - autoFocus // eslint-disable-line jsx-a11y/no-autofocus - /> - - - {candidates.length > 0 && ( - - setOwner(val)} - > - {candidates.map((owner) => ( - - ))} - - - )} -
-
-
- ); + return ( + dialogResult.resolve()} + variant={ModalVariant.small} + footer={ + <> + + + + } + > + + {errorMessage !== undefined && ( + + )} +
{ + createDirectory(); + e.preventDefault(); + return false; + }} + > + + { + setNameError(check_name(val)); + setErrorMessage(undefined); + setName(val); + }} + id="create-directory-input" + autoFocus // eslint-disable-line jsx-a11y/no-autofocus + /> + + + {candidates.length > 0 && ( + + setOwner(val)} + > + {candidates.map((owner) => ( + + ))} + + + )} +
+
+
+ ); }; 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 ( - - - - - } - > - - {errorMessage !== undefined && ( - - )} -
- {superuser.allowed && accounts && groups && ( - - - changeOwner(val)} - id="edit-permissions-owner" - value={owner} - > - {accounts?.sort(sortByName).map((a) => { - return ( - - ); - })} - - - - setGroup(val)} - id="edit-permissions-group" - value={group} - > - {groups?.sort(sortByName).map((g) => { - return ( - - ); - })} - - - - )} - - - > 6) & 7} - onChange={(_, val) => { - setMode((mode & 0o077) | (val << 6)); - }} - id="edit-permissions-owner-access" - > - {permissions_options()} - - - - > 3) & 7} - onChange={(_, val) => { - setMode((mode & 0o707) | (val << 3)); - }} - id="edit-permissions-group-access" - > - {permissions_options()} - - - - { - setMode((mode & 0o770) | val); - }} - id="edit-permissions-other-access" - > - {permissions_options()} - - - -
-
-
- ); + 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 ( + + + + + } + > + + {errorMessage !== undefined && ( + + )} +
+ {superuser.allowed && accounts && groups && ( + + + changeOwner(val)} + id="edit-permissions-owner" + value={owner} + > + {accounts?.sort(sortByName).map((a) => { + return ( + + ); + })} + + + + setGroup(val)} + id="edit-permissions-group" + value={group} + > + {groups?.sort(sortByName).map((g) => { + return ( + + ); + })} + + + + )} + + + > 6) & 7} + onChange={(_, val) => { + setMode((mode & 0o077) | (val << 6)); + }} + id="edit-permissions-owner-access" + > + {permissions_options()} + + + + > 3) & 7} + onChange={(_, val) => { + setMode((mode & 0o707) | (val << 3)); + }} + id="edit-permissions-group-access" + > + {permissions_options()} + + + + { + setMode((mode & 0o770) | val); + }} + id="edit-permissions-other-access" + > + {permissions_options()} + + + +
+
+
+ ); }; 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 = ( - <> - - {overrideFileName && ( - - )} - - - ); - - const label = selected.type !== "dir" ? _("New filename") : _("New name"); - - return ( - {selected.name})} - variant={ModalVariant.small} - isOpen - onClose={() => dialogResult.resolve()} - footer={footer} - > - - {errorMessage !== undefined && ( - - )} -
{ - e.preventDefault(); - if (name !== selected.name) renameItem(); - else { - setNameError(checkName(name, cwdInfo?.entries || {}, selected)); - } - return false; - }} - > - - { - setNameError(checkName(val, cwdInfo?.entries || {}, selected)); - setOverrideFileName( - checkCanOverride(val, cwdInfo?.entries || {}, selected), - ); - setErrorMessage(undefined); - setName(val); - }} - id="rename-item-input" - /> - - -
-
-
- ); + 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 = ( + <> + + {overrideFileName && ( + + )} + + + ); + + const label = selected.type !== "dir" ? _("New filename") : _("New name"); + + return ( + {selected.name})} + variant={ModalVariant.small} + isOpen + onClose={() => dialogResult.resolve()} + footer={footer} + > + + {errorMessage !== undefined && ( + + )} +
{ + e.preventDefault(); + if (name !== selected.name) renameItem(); + else { + setNameError( + checkName( + name, + cwdInfo?.entries || {}, + selected, + ), + ); + } + return false; + }} + > + + { + setNameError( + checkName( + val, + cwdInfo?.entries || {}, + selected, + ), + ); + setOverrideFileName( + checkCanOverride( + val, + cwdInfo?.entries || {}, + selected, + ), + ); + setErrorMessage(undefined); + setName(val); + }} + id="rename-item-input" + /> + + +
+
+
+ ); }; 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 && ( - - - {dir !== "" && ( -

- / -

- )} -
- ); - })} - {editMode && newPath !== null && ( - - event.target.select()} - onKeyDown={handleInputKey} - onChange={(_event, value) => setNewPath(value)} - /> - - )} - - {editMode && ( - <> - + {dir !== "" && ( +

+ / +

+ )} +
+ ); + })} + {editMode && newPath !== null && ( + + event.target.select()} + onKeyDown={handleInputKey} + onChange={(_event, value) => setNewPath(value)} + /> + + )} + + {editMode && ( + <> + - - )} - - ); + return ( + + + + + + {selected.length === 1 + ? selected[0].name + : directory_name} + + {selected.length === 0 && ( + + {shown_items} + + )} + {selected.length > 1 && ( + + {cockpit.format( + "$0 items selected", + selected.length, + )} + + )} + {selected.length === 1 && ( + {info} + )} + + + + + {selected.length === 1 && ( + + + {getDescriptionListItems(selected[0]).map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + + + + )} + + ); }; 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={ - <> - - {isMultiUpload && ( - - )} - - - } - > -

- {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={ + <> + + {isMultiUpload && ( + + )} + + + } + > +

+ {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 ( - - - - - - - - ); + 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 ( + + + + + + + + ); }; 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("/"), )}