mirror of
https://github.com/45Drives/cockpit-navigator.git
synced 2025-07-31 01:24:37 +02:00
use find instead of dir+stat
This commit is contained in:
parent
3e1864b329
commit
71b96b3f8b
@ -12,7 +12,7 @@
|
||||
<div class="relative w-6">
|
||||
<component :is="icon" class="size-icon icon-default" />
|
||||
<LinkIcon
|
||||
v-if="entry.type === 'symbolic link'"
|
||||
v-if="entry.type === 'l'"
|
||||
class="w-2 h-2 absolute right-0 bottom-0 text-default"
|
||||
/>
|
||||
</div>
|
||||
@ -21,7 +21,7 @@
|
||||
<ChevronUpIcon v-else class="size-icon icon-default" />
|
||||
</button>
|
||||
<div v-html="escapeStringHTML(entry.name)" :title="entry.name"></div>
|
||||
<div v-if="entry.type === 'symbolic link'" class="inline-flex gap-1 items-center">
|
||||
<div v-if="entry.type === 'l'" class="inline-flex gap-1 items-center">
|
||||
<div class="inline relative">
|
||||
<ArrowNarrowRightIcon class="text-default size-icon-sm inline" />
|
||||
<XIcon
|
||||
@ -89,7 +89,7 @@
|
||||
:title="`-> ${entry.target?.rawPath ?? '?'}`"
|
||||
>
|
||||
<LinkIcon
|
||||
v-if="entry.type === 'symbolic link'"
|
||||
v-if="entry.type === 'l'"
|
||||
:class="[entry.target?.broken ? 'text-red-300 dark:text-red-800' : 'text-gray-100 dark:text-gray-900', 'w-4 h-auto']"
|
||||
/>
|
||||
</div>
|
||||
@ -161,7 +161,7 @@ export default {
|
||||
}
|
||||
|
||||
watch(props.entry, () => {
|
||||
if (props.entry.type === 'directory' || (props.entry.type === 'symbolic link' && props.entry.target?.type === 'directory')) {
|
||||
if (props.entry.type === 'd' || (props.entry.type === 'l' && props.entry.target?.type === 'd')) {
|
||||
icon.value = FolderIcon;
|
||||
directoryLike.value = true;
|
||||
} else {
|
||||
|
@ -23,7 +23,6 @@ import { ref, reactive, computed, inject, watch, onBeforeUnmount, onMounted } fr
|
||||
import { useSpawn, errorString, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
|
||||
import { notificationsInjectionKey, settingsInjectionKey, clipboardInjectionKey } from '../keys';
|
||||
import DirectoryEntry from './DirectoryEntry.vue';
|
||||
import getDirListing from '../functions/getDirListing';
|
||||
import getDirEntryObjects from '../functions/getDirEntryObjects';
|
||||
import FileSystemWatcher from '../functions/fileSystemWatcher';
|
||||
|
||||
@ -61,13 +60,13 @@ export default {
|
||||
const sortCallbackComputed = computed(() => {
|
||||
return (a, b) => {
|
||||
if (settings.directoryView?.separateDirs) {
|
||||
const checkA = a.type === 'symbolic link' ? (a.target?.type ?? null) : a.type;
|
||||
const checkB = b.type === 'symbolic link' ? (b.target?.type ?? null) : b.type;
|
||||
const checkA = a.type === 'l' ? (a.target?.type ?? null) : a.type;
|
||||
const checkB = b.type === 'l' ? (b.target?.type ?? null) : b.type;
|
||||
if (checkA === null || checkB === null)
|
||||
return 0;
|
||||
if (checkA === 'directory' && checkB !== 'directory')
|
||||
if (checkA === 'd' && checkB !== 'd')
|
||||
return -1;
|
||||
else if (checkA !== 'directory' && checkB === 'directory')
|
||||
else if (checkA !== 'd' && checkB === 'd')
|
||||
return 1;
|
||||
}
|
||||
return props.sortCallback(a, b);
|
||||
@ -152,10 +151,10 @@ export default {
|
||||
processingHandler.start();
|
||||
try {
|
||||
const cwd = props.path;
|
||||
const entryNames = await getDirListing(cwd, props.host, (message) => notifications.value.constructNotification("Failed to parse file name", message, 'error'));
|
||||
// const entryNames = await getDirListing(cwd, props.host, (message) => notifications.value.constructNotification("Failed to parse file name", message, 'error'));
|
||||
console.time('getEntries');
|
||||
const tmpEntries = (
|
||||
await getDirEntryObjects(
|
||||
entryNames,
|
||||
cwd,
|
||||
props.host,
|
||||
(message) => notifications.value.constructNotification("Failed to parse file name", message, 'error')
|
||||
@ -164,6 +163,7 @@ export default {
|
||||
if (props.path !== cwd)
|
||||
return; // changed directory before could finish
|
||||
entries.value = [...tmpEntries.sort(sortCallbackComputed.value)].map(entry => reactive({...entry, cut: clipboard.content.find(a => a.path === entry.path && a.host === entry.host) ?? false}));
|
||||
console.timeEnd('getEntries');
|
||||
} catch (error) {
|
||||
entries.value = [];
|
||||
notifications.value.constructNotification("Error getting directory entries", errorStringHTML(error), 'error');
|
||||
@ -184,7 +184,7 @@ export default {
|
||||
|
||||
const emitStats = () => {
|
||||
emit('updateStats', visibleEntries.value.reduce((stats, entry) => {
|
||||
if (entry.type === 'directory' || (entry.type === 'symbolic link' && entry.target?.type === 'directory'))
|
||||
if (entry.type === 'd' || (entry.type === 'l' && entry.target?.type === 'd'))
|
||||
stats.dirs++;
|
||||
else
|
||||
stats.files++;
|
||||
|
@ -142,7 +142,7 @@ export default {
|
||||
let destination;
|
||||
if (selected.length === 1) {
|
||||
destination = selected[0];
|
||||
if (destination.type !== 'directory' && !(destination.type === 'symbolic link' && destination.target.type === 'directory')) {
|
||||
if (destination.type !== 'd' && !(destination.type === 'l' && destination.target.type === 'd')) {
|
||||
notifications.value.constructNotification("Paste Failed", 'Cannot paste to non-directory.', 'error');
|
||||
return;
|
||||
}
|
||||
|
@ -1,97 +1,98 @@
|
||||
import { useSpawn, errorString } from "@45drives/cockpit-helpers";
|
||||
import { UNIT_SEPARATOR, RECORD_SEPARATOR } from "../constants";
|
||||
|
||||
async function processLinks(linkTargets, host) {
|
||||
if (linkTargets.length === 0)
|
||||
return;
|
||||
(
|
||||
await useSpawn(
|
||||
// Separate by newline so errors will slot in
|
||||
['stat', `--printf=%F${UNIT_SEPARATOR}%f\n`, ...linkTargets.map(target => target.path)],
|
||||
{ superuser: 'try', err: 'out', host } // stderr >&stdout for maintaining index order
|
||||
).promise()
|
||||
.catch(state => state) // ignore errors, error message will maintain index order
|
||||
).stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(record => record)
|
||||
.map((record, index) => {
|
||||
if (record.includes(UNIT_SEPARATOR)) {
|
||||
const [type, mode] = record.split(UNIT_SEPARATOR);
|
||||
linkTargets[index].type = type;
|
||||
linkTargets[index].mode = mode;
|
||||
linkTargets[index].broken = false;
|
||||
} else { // error
|
||||
linkTargets[index].broken = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of directory entry objects from list of directory entry names
|
||||
*
|
||||
* @param {String[]} dirListing - List of entry names
|
||||
* @param {String} cwd - Working directory to run stat in
|
||||
* @param {String} host - Host to run stat on
|
||||
* find -H path -maxdepth 1 -mindepth 1 -printf '%f:%m:%M:%s:%u:%g:%B@:%T@:%A@:%y:%Y:%l\n'
|
||||
*
|
||||
* @param {String} cwd - Working directory to run find in
|
||||
* @param {String} host - Host to run find on
|
||||
* @param {getDirEntryObjectsFailCallback} failCallback - Callback function for handling errors, receives {String} message
|
||||
* @param {ByteFormatter} byteFormatter - Function to format bytes
|
||||
* @returns {Promise<DirectoryEntryObj[]>} Array of DirectoryEntryObj objects
|
||||
*/
|
||||
async function getDirEntryObjects(dirListing, cwd, host, failCallback, byteFormatter = cockpit.format_bytes) {
|
||||
async function getDirEntryObjects(cwd, host, failCallback, byteFormatter = cockpit.format_bytes) {
|
||||
const fields = [
|
||||
'%n', // path
|
||||
'%f', // mode (raw hex)
|
||||
'%A', // modeStr
|
||||
'%f', // name
|
||||
'%p', // full path
|
||||
'%m', // mode (octal)
|
||||
'%M', // modeStr
|
||||
'%s', // size
|
||||
'%U', // owner
|
||||
'%G', // group
|
||||
'%W', // ctime
|
||||
'%Y', // mtime
|
||||
'%X', // atime
|
||||
'%F', // type
|
||||
'%N', // quoted name with symlink
|
||||
'%u', // owner
|
||||
'%g', // group
|
||||
'%B@', // ctime
|
||||
'%T@', // mtime
|
||||
'%A@', // atime
|
||||
'%y', // type
|
||||
'%Y', // symlink target type or type if not symlink
|
||||
'%l', // symlink target name or '' if not symlink
|
||||
]
|
||||
const entries = dirListing.length
|
||||
? parseRawEntryStats(
|
||||
(
|
||||
await useSpawn([
|
||||
'stat',
|
||||
`--printf=${fields.join(UNIT_SEPARATOR)}${RECORD_SEPARATOR}`,
|
||||
...dirListing
|
||||
], { superuser: 'try', directory: cwd, host }
|
||||
)
|
||||
.promise()
|
||||
.catch(state => state) // ignore errors
|
||||
).stdout, cwd, host, failCallback, byteFormatter)
|
||||
: [];
|
||||
await processLinks(entries.filter(entry => entry.type === 'symbolic link').map(entry => entry.target), host);
|
||||
return entries;
|
||||
return parseRawEntryStats(
|
||||
await getDirEntryStats(cwd, host, fields, [], false),
|
||||
cwd,
|
||||
host,
|
||||
failCallback,
|
||||
byteFormatter
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw output of `stat` call from {@link getDirEntryObjects()}
|
||||
*
|
||||
* @param {String} raw - Raw output of `stat` call from {@link getDirEntryObjects()}
|
||||
* @param {String} cwd - Path to working directory to run stat in
|
||||
* @param {String} host - Host to run stat on
|
||||
* @param {String} cwd - Starting point for find
|
||||
* @param {String} host - Host to run find on
|
||||
* @param {String[]} outputFormat - -printf format fields array (man find(1))
|
||||
* @param {String[]} extraFindArguments - extra tests and actions to run on files
|
||||
* @param {Boolean} recursive - if false, -maxdepth is 1
|
||||
* @returns {Promise<String[][]>} Array of resultant output
|
||||
*/
|
||||
async function getDirEntryStats(cwd, host, outputFormat, extraFindArguments = [], recursive = false) {
|
||||
const UNIT_SEPARATOR_ESC = `\\${UNIT_SEPARATOR.charCodeAt(0).toString(8).padStart(3, '0')}`;
|
||||
const RECORD_SEPARATOR_ESC = `\\${RECORD_SEPARATOR.charCodeAt(0).toString(8).padStart(3, '0')}`;
|
||||
const argv = [
|
||||
'find',
|
||||
'-H',
|
||||
cwd,
|
||||
'-mindepth',
|
||||
'1',
|
||||
];
|
||||
if (!recursive)
|
||||
argv.push('-maxdepth', '1');
|
||||
argv.push(...extraFindArguments);
|
||||
if (outputFormat.length)
|
||||
argv.push('-printf', `${outputFormat.join(UNIT_SEPARATOR_ESC)}${RECORD_SEPARATOR_ESC}`);
|
||||
return new TextDecoder().decode(
|
||||
( // make sure any possible nul bytes don't break cockpit's protocol by using binary
|
||||
await useSpawn(
|
||||
argv,
|
||||
{ superuser: 'try', host, binary: true }
|
||||
).promise()
|
||||
).stdout
|
||||
).split(RECORD_SEPARATOR)
|
||||
.slice(0, -1) // remove last empty array element from split since all entries end with RECORD_SEPARATOR
|
||||
.map(record => record.split(UNIT_SEPARATOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw output of `find` call from {@link getDirEntryObjects()}
|
||||
*
|
||||
* @param {String[][]} records - Raw output of `find` call from {@link getDirEntryObjects()}
|
||||
* @param {String} cwd - Path to working directory that find was ran in
|
||||
* @param {String} host - Host that find was ran on
|
||||
* @param {getDirEntryObjectsFailCallback} failCallback - Callback function for handling errors, receives {String} message
|
||||
* @param {ByteFormatter} byteFormatter - Function to format bytes
|
||||
* @returns {DirectoryEntryObj[]}
|
||||
*/
|
||||
function parseRawEntryStats(raw, cwd, host, failCallback, byteFormatter = cockpit.format_bytes) {
|
||||
const UNIT_SEPARATOR = '\x1F'; // "Unit Separator" - ASCII field delimiter
|
||||
const RECORD_SEPARATOR = '\x1E'; // "Record Separator" - ASCII record delimiter
|
||||
return raw.split(RECORD_SEPARATOR)
|
||||
.filter(record => record) // remove empty lines
|
||||
.map(record => {
|
||||
function parseRawEntryStats(records, cwd, host, failCallback, byteFormatter = cockpit.format_bytes) {
|
||||
return records.map(fields => {
|
||||
try {
|
||||
let [name, mode, modeStr, size, owner, group, ctime, mtime, atime, type, symlinkStr] = record.split(UNIT_SEPARATOR);
|
||||
let [name, path, mode, modeStr, size, owner, group, ctime, mtime, atime, type, symlinkTargetType, symlinkTargetName] = fields;
|
||||
[size, ctime, mtime, atime] = [size, ctime, mtime, atime].map(num => parseInt(num));
|
||||
[ctime, mtime, atime] = [ctime, mtime, atime].map(ts => ts ? new Date(ts * 1000) : null);
|
||||
mode = parseInt(mode, 16);
|
||||
const entry = {
|
||||
mode = parseInt(mode, 8);
|
||||
return {
|
||||
name,
|
||||
path: `${cwd}/${name}`.replace(/\/+/g, '/'),
|
||||
path,
|
||||
mode,
|
||||
modeStr,
|
||||
size,
|
||||
@ -102,26 +103,24 @@ function parseRawEntryStats(raw, cwd, host, failCallback, byteFormatter = cockpi
|
||||
mtime,
|
||||
atime,
|
||||
type,
|
||||
target: {},
|
||||
target: {
|
||||
type: symlinkTargetType,
|
||||
rawPath: symlinkTargetName,
|
||||
path: type === 'l' ? symlinkTargetName.replace(/^(?!\/)/, `${cwd}/`) : '',
|
||||
broken: ['L', 'N', '?'].includes(symlinkTargetType), // L: loop N: nonexistent ?: error
|
||||
},
|
||||
selected: false,
|
||||
host,
|
||||
cut: false,
|
||||
};
|
||||
if (type === 'symbolic link') {
|
||||
entry.target.rawPath = [
|
||||
...symlinkStr.split(/\s*->\s*/)[1].trim().matchAll(/\$?'([^']+)'/g)
|
||||
].map(group => JSON.parse(`"${group[1]}"`)).join('');
|
||||
entry.target.path = entry.target.rawPath.replace(/^(?!\/)/, `${cwd}/`);
|
||||
}
|
||||
return entry;
|
||||
} catch (error) {
|
||||
failCallback(errorString(error) + `\ncaused by: ${record}`);
|
||||
failCallback(errorString(error) + `\ncaused by: ${fields.toString()}`);
|
||||
return null;
|
||||
}
|
||||
}).filter(entry => entry !== null)
|
||||
}
|
||||
|
||||
export { parseRawEntryStats };
|
||||
export { getDirEntryObjects, getDirEntryStats, parseRawEntryStats };
|
||||
|
||||
export default getDirEntryObjects;
|
||||
|
||||
@ -155,10 +154,11 @@ export default getDirEntryObjects;
|
||||
* @property {Date} ctime - Creation time
|
||||
* @property {Date} mtime - Last Modified time
|
||||
* @property {Date} atime - Last Accessed time
|
||||
* @property {String} type - Type of inode returned by stat
|
||||
* @property {String} type - Type of inode returned by find
|
||||
* @property {Object} target - Object for symlink target
|
||||
* @property {String} target.rawPath - Symlink target path directly grabbed from stat
|
||||
* @property {String} target.rawPath - Symlink target path directly grabbed from find
|
||||
* @property {String} target.path - Resolved symlink target path
|
||||
* @property {Boolean} target.broken - Whether or not the link is broken
|
||||
* @property {Boolean} selected - Whether or not the user has selected this entry in the browser
|
||||
* @property {String} host - host that owns entry
|
||||
* @property {Boolean} cut - whether or not the file is going to be cut
|
||||
|
Loading…
x
Reference in New Issue
Block a user