flatten entry object, make actions specific to component

This commit is contained in:
joshuaboud 2022-06-17 13:39:37 -03:00
parent b528a7f57c
commit 06c6a04d59
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
5 changed files with 135 additions and 101 deletions

View File

@ -5,28 +5,28 @@
<td class="!pl-1 relative">
<div :class="[entry.cut ? 'line-through' : '', 'flex items-center gap-1']">
<div class="w-6" v-for="i in Array(level).fill(0)" v-memo="[level]"></div>
<div class="relative w-6">
<component :is="icon" class="size-icon icon-default"
:class="{ 'text-gray-500/50': entry.cut }" />
<div class="relative w-6" :class="[entry.cut ? 'text-gray-500/50' : 'icon-default']">
<FolderIcon v-if="entry.resolvedType === 'd'" class="size-icon" />
<DocumentIcon v-else class="size-icon" />
<LinkIcon v-if="entry.type === 'l'" class="w-2 h-2 absolute right-0 bottom-0 text-default" />
</div>
<button class="z-10" v-if="directoryLike" @click.stop="toggleShowEntries" @mouseenter="hover = true"
<button class="z-10 icon-default" v-if="entry.resolvedType === 'd'" @click.stop="toggleShowEntries" @mouseenter="hover = true"
@mouseleave="hover = false">
<ChevronDownIcon v-if="!showEntries" class="size-icon icon-default" />
<ChevronUpIcon v-else class="size-icon icon-default" />
<ChevronDownIcon v-if="!showEntries" class="size-icon" />
<ChevronUpIcon v-else class="size-icon" />
</button>
<div v-html="entry.nameHTML" :title="entry.name"></div>
<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 v-if="entry.target?.broken"
<XIcon v-if="entry.linkBroken"
class="icon-danger size-icon-sm absolute inset-x-0 bottom-0" />
</div>
<div v-html="entry.target?.rawPathHTML ?? ''" :title="entry.target.rawPath"></div>
<div v-html="entry.linkRawPathHTML ?? ''" :title="entry.linkRawPath"></div>
</div>
</div>
<div class="absolute left-0 top-0 bottom-0 w-full max-w-[50vw]" @mouseup.stop
@click.prevent="$emit('entryAction', 'toggleSelected', entry, $event)"
@click.prevent="$emit('directoryViewAction', 'toggleSelected', entry, $event)"
@dblclick="doubleClickCallback" @mouseenter="hover = true" @mouseleave="hover = false"
ref="selectIntersectElement" />
</td>
@ -39,8 +39,8 @@
<td v-if="settings?.directoryView?.cols?.size" class="font-mono text-right">{{
entry.sizeHuman
}}</td>
<td v-if="settings?.directoryView?.cols?.ctime">{{
entry.ctimeStr
<td v-if="settings?.directoryView?.cols?.btime">{{
entry.btimeStr
}}</td>
<td v-if="settings?.directoryView?.cols?.mtime">{{
entry.mtimeStr
@ -49,23 +49,25 @@
entry.atimeStr
}}</td>
</tr>
<component :is="DirectoryEntryList" v-if="directoryLike && showEntries" :host="host" :path="entry.path"
<component :is="DirectoryEntryList" v-if="entry.resolvedType === 'd' && showEntries" :host="host" :path="entry.path"
:isChild="true" :sortCallback="inheritedSortCallback" :searchFilterRegExp="searchFilterRegExp"
@startProcessing="(...args) => $emit('startProcessing', ...args)"
@stopProcessing="(...args) => $emit('stopProcessing', ...args)" @cancelShowEntries="showEntries = false"
ref="directoryEntryListRef" :level="level + 1" :selectedCount="selectedCount"
@entryAction="(...args) => $emit('entryAction', ...args)" />
@browserAction="(...args) => $emit('browserAction', ...args)"
@directoryViewAction="(...args) => $emit('directoryViewAction', ...args)" />
</template>
<div v-else class="select-none dir-entry flex flex-col items-center overflow-hidden dir-entry-width p-2"
:class="{ '!bg-red-600/10': hover && !entry.selected, '!bg-red-600/20': hover && entry.selected, 'dir-entry-selected': entry.selected, '!border-t-transparent': suppressBorderT, '!border-b-transparent': suppressBorderB, '!border-l-transparent': suppressBorderL, '!border-r-transparent': suppressBorderR }">
<div class="w-full" @dblclick="doubleClickCallback" @click.prevent="$emit('entryAction', 'toggleSelected', entry, $event)"
<div class="w-full" @dblclick="doubleClickCallback" @click.prevent="$emit('directoryViewAction', 'toggleSelected', entry, $event)"
@mouseup.stop @mouseenter="hover = true" @mouseleave="hover = false" ref="selectIntersectElement">
<div class="relative w-full">
<component :is="icon" class="icon-default w-full h-auto" :class="{ 'text-gray-500/50': entry.cut }" />
<div :class="[directoryLike ? 'right-[15%] bottom-[25%]' : 'right-[25%] bottom-[15%]', 'inline absolute w-[20%]']"
:title="`-> ${entry.target?.rawPath ?? '?'}`">
<div class="relative w-full" :class="[entry.cut ? 'text-gray-500/50' : 'icon-default']">
<FolderIcon v-if="entry.resolvedType === 'd'" class="w-full h-auto" />
<DocumentIcon v-else class="w-full h-auto" />
<div :class="[entry.resolvedType === 'd' ? 'right-[15%] bottom-[25%]' : 'right-[25%] bottom-[15%]', 'inline absolute w-[20%]']"
:title="`-> ${entry.linkRawPath ?? '?'}`">
<LinkIcon v-if="entry.type === 'l'"
:class="[entry.target?.broken ? 'text-red-300 dark:text-red-800' : 'text-gray-100 dark:text-gray-900']" />
:class="[entry.linkBroken ? 'text-red-300 dark:text-red-800' : 'text-gray-100 dark:text-gray-900']" />
</div>
</div>
<div class="text-center w-full text-sm break-words"
@ -77,7 +79,8 @@
<div>
<span v-if="level > 0">{{ entry.path.split('/').slice(-1 * (level + 1)).join('/') }}:</span>
<span v-else>{{ entry.name }}:</span>
{{ entry.mode.toString(8) }}, {{ entry.owner }}:{{ entry.group }}, {{ entry.sizeHuman }}
{{ entry.mode.toString(8) }}, {{ entry.owner }}:{{ entry.group }}, {{ entry.sizeHuman }},
modified: {{ entry.mtime }}
</div>
</Teleport>
</template>
@ -85,7 +88,7 @@
<script>
import { ref, inject, watch, nextTick, onBeforeUnmount, onMounted, onActivated, onDeactivated, onUpdated } from 'vue';
import { DocumentIcon, FolderIcon, LinkIcon, DocumentRemoveIcon, ArrowNarrowRightIcon, XIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/solid';
import { settingsInjectionKey } from '../keys';
import { notificationsInjectionKey, settingsInjectionKey } from '../keys';
import DirectoryEntryList from './DirectoryEntryList.vue';
import { escapeStringHTML } from '../functions/escapeStringHTML';
@ -109,26 +112,19 @@ export default {
},
setup(props, { emit }) {
const settings = inject(settingsInjectionKey);
const icon = ref(FolderIcon);
const directoryLike = ref(false);
const notifications = inject(notificationsInjectionKey);
const showEntries = ref(false);
const directoryEntryListRef = ref();
const selectIntersectElement = ref();
const hover = ref(false);
if (props.entry.type === 'd' || (props.entry.type === 'l' && props.entry.target?.type === 'd')) {
icon.value = FolderIcon;
directoryLike.value = true;
} else {
icon.value = DocumentIcon;
directoryLike.value = false;
}
const doubleClickCallback = () => {
if (directoryLike.value) {
emit('entryAction', 'cd', props.entry);
if (props.entry.resolvedType === 'd') {
emit('browserAction', 'cd', props.entry);
} else if (props.entry.resolvedType === 'f') {
emit('browserAction', 'openFilePrompt', props.entry);
} else {
emit('entryAction', 'openPrompt', props.entry);
notifications.value.constructNotification('Cannot open or download file', `${props.entry.nameHTML} is a ${props.entry.resolvedTypeHuman}`, 'denied');
}
}
@ -162,8 +158,6 @@ export default {
return {
settings,
icon,
directoryLike,
showEntries,
directoryEntryListRef,
doubleClickCallback,
@ -192,7 +186,8 @@ export default {
'startProcessing',
'stopProcessing',
'setEntryProp',
'entryAction',
'browserAction',
'directoryViewAction',
]
}
</script>

View File

@ -5,7 +5,8 @@
@startProcessing="(...args) => $emit('startProcessing', ...args)"
@stopProcessing="(...args) => $emit('stopProcessing', ...args)" ref="entryRefs" :level="level" :selectedCount="selectedCount"
@setEntryProp="(prop, value) => entry[prop] = value"
@entryAction="(...args) => $emit('entryAction', ...args)"
@browserAction="(...args) => $emit('browserAction', ...args)"
@directoryViewAction="(...args) => $emit('directoryViewAction', ...args)"
:suppressBorderT="visibleEntries[index - cols]?.selected && !(visibleEntries[index - cols]?.dirOpen)"
:suppressBorderB="visibleEntries[index + cols]?.selected && !(entry.dirOpen)"
:suppressBorderL="settings.directoryView.view !== 'list' && (visibleEntries[index - 1]?.selected && (index) % cols !== 0)"
@ -69,13 +70,9 @@ export default {
const sortCallbackComputed = computed(() => {
return (a, b) => {
if (settings.directoryView?.separateDirs) {
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 === 'd' && checkB !== 'd')
if (a.resolvedType === 'd' && b.resolvedType !== 'd')
return -1;
else if (checkA !== 'd' && checkB === 'd')
else if (a.resolvedType !== 'd' && b.resolvedType === 'd')
return 1;
}
return props.sortCallback(a, b);
@ -231,7 +228,7 @@ export default {
visibleEntries.value = entries.value.filter(entryFilterCallback);
// nextTick(() => console.timeEnd('updateVisibleEntries-' + props.path));
const _stats = visibleEntries.value.reduce((_stats, entry) => {
if (entry.type === 'd' || (entry.type === 'l' && entry.target?.type === 'd'))
if (entry.resolvedType === 'd')
_stats.dirs++;
else
_stats.files++;
@ -276,7 +273,8 @@ export default {
'stopProcessing',
'cancelShowEntries',
'deselectAll',
'entryAction',
'browserAction',
'directoryViewAction',
'tallySelected',
]
}

View File

@ -64,10 +64,10 @@
</template>
<template #tbody>
<DirectoryEntryList :host="host" :path="path" :sortCallback="sortCallback"
:searchFilterRegExp="searchFilterRegExp" @cd="(...args) => $emit('cd', ...args)"
@edit="(...args) => $emit('edit', ...args)"
:searchFilterRegExp="searchFilterRegExp"
@startProcessing="processing++" @stopProcessing="processing--"
@entryAction="handleEntryAction" ref="directoryEntryListRef"
@browserAction="(...args) => $emit('browserAction', ...args)"
@directoryViewAction="handleAction" ref="directoryEntryListRef"
@tallySelected="tallySelected"
:level="0" :cols="1" :selectedCount="selectedCount" />
</template>
@ -109,7 +109,8 @@
<DirectoryEntryList :host="host" :path="path" :sortCallback="sortCallback"
:searchFilterRegExp="searchFilterRegExp"
@startProcessing="processing++" @stopProcessing="processing--"
@entryAction="handleEntryAction"
@browserAction="(...args) => $emit('browserAction', ...args)"
@directoryViewAction="handleAction"
@tallySelected="tallySelected" ref="directoryEntryListRef"
:level="0" :cols="cols" :selectedCount="selectedCount" />
</div>
@ -291,7 +292,7 @@ export default {
let destination;
if (selected.length === 1) {
destination = selected[0];
if (destination.type !== 'd' && !(destination.type === 'l' && destination.target.type === 'd')) {
if (destination.resolvedType !== 'd') {
notifications.value.constructNotification("Paste Failed", 'Cannot paste to non-directory.', 'error');
break;
}
@ -326,13 +327,13 @@ export default {
}
}
const handleEntryAction = (action, entry, event, ...args) => {
const handleAction = (action, ...args) => {
switch (action) {
case 'toggleSelected':
toggleSelected(entry, event);
toggleSelected(...args);
break;
default:
emit('entryAction', action, entry, event, ...args);
console.error('Unknown directoryViewAction:', action, args);
break;
}
}
@ -377,7 +378,7 @@ export default {
selectAll,
deselectAll,
selectRectangle,
handleEntryAction,
handleAction,
tallySelected,
}
},
@ -389,7 +390,7 @@ export default {
DragSelectArea,
},
emits: [
'entryAction',
'browserAction',
]
}
</script>

View File

@ -1,12 +1,12 @@
import { useSpawn, errorString } from "@45drives/cockpit-helpers";
import { UNIT_SEPARATOR, RECORD_SEPARATOR } from "../constants";
import { szudzikPair } from "./szudzikPair";
import { szudzikPair, hashString } from "./szudzikPair";
import { escapeStringHTML } from "./escapeStringHTML";
/**
* Get list of directory entry objects from list of directory entry names
*
* find -H path -maxdepth 1 -mindepth 1 -printf '%D:%i%f:%m:%M:%s:%u:%g:%B@:%T@:%A@:%y:%Y:%l\n'
* find -H path -maxdepth 1 -mindepth 1 -printf '%D:%i%f:%p:%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
@ -25,7 +25,7 @@ async function getDirEntryObjects(cwd, host, extraFindArgs = [], failCallback =
'%s', // size
'%u', // owner
'%g', // group
'%B@', // ctime
'%B@', // btime
'%T@', // mtime
'%A@', // atime
'%y', // type
@ -96,17 +96,31 @@ async function getDirEntryStats(cwd, host, outputFormat, extraFindArguments = []
* @returns {DirectoryEntryObj[]}
*/
function parseRawEntryStats(records, cwd, host, failCallback, byteFormatter = cockpit.format_bytes) {
const typeHumanLUT = {
f: 'regular file',
d: 'directory',
l: 'symbolic link',
c: 'character device',
b: 'block device',
p: 'FIFO (named pipe)',
s: 'socket',
U: 'unknown file type',
L: 'broken link (loop)',
N: 'broken link (non-existent)',
D: 'door (Solaris)', // probably don't need this, but why not right?
}
const hostHash = hashString(host);
return records.map(fields => {
try {
let [devId, inode, 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));
let [devId, inode, name, path, mode, modeStr, size, owner, group, btime, mtime, atime, type, symlinkTargetType, symlinkTargetName] = fields;
[size, btime, mtime, atime] = [size, btime, mtime, atime].map(num => parseInt(num));
[devId, inode] = [devId, inode].map(num => BigInt(num));
[ctime, mtime, atime] = [ctime, mtime, atime].map(ts => (ts && ts > 0) ? new Date(ts * 1000) : null);
let [ctimeStr, mtimeStr, atimeStr] = [ctime, mtime, atime].map(date => date?.toLocaleString() ?? '-');
[btime, mtime, atime] = [btime, mtime, atime].map(ts => (ts && ts > 0) ? new Date(ts * 1000) : null);
let [btimeStr, mtimeStr, atimeStr] = [btime, mtime, atime].map(date => date?.toLocaleString() ?? '-');
let [nameHTML, symlinkTargetNameHTML] = [name, symlinkTargetName].map(escapeStringHTML);
mode = parseInt(mode, 8);
return {
uniqueId: szudzikPair(host, devId, inode),
uniqueId: szudzikPair(hostHash, devId, inode),
devId,
inode,
name,
@ -118,20 +132,21 @@ function parseRawEntryStats(records, cwd, host, failCallback, byteFormatter = co
sizeHuman: byteFormatter(size, 1000).replace(/(?<!B)$/, ' B'),
owner,
group,
ctime,
btime,
mtime,
atime,
ctimeStr,
btimeStr,
mtimeStr,
atimeStr,
type,
target: {
type: symlinkTargetType,
rawPath: symlinkTargetName,
rawPathHTML: symlinkTargetNameHTML,
path: type === 'l' ? symlinkTargetName.replace(/^(?!\/)/, `${cwd}/`) : '',
broken: ['L', 'N', '?'].includes(symlinkTargetType), // L: loop N: nonexistent ?: error
},
typeHuman: typeHumanLUT[type],
linkType: symlinkTargetType || null,
linkRawPath: symlinkTargetName || null,
linkRawPathHTML: symlinkTargetNameHTML || null,
linkBroken: ['L', 'N', '?'].includes(symlinkTargetType),
resolvedPath: type === 'l' ? symlinkTargetName.replace(/^(?!\/)/, `${cwd}/`) : path,
resolvedType: symlinkTargetType || type,
resolvedTypeHuman: typeHumanLUT[symlinkTargetType || type],
selected: false,
host,
cut: false,
@ -177,17 +192,21 @@ export default getDirEntryObjects;
* @property {String} sizeHuman - Human readable size
* @property {String} owner - File owner
* @property {String} group - File group
* @property {Date} ctime - Creation time
* @property {Date} btime - Creation time
* @property {Date} mtime - Last Modified time
* @property {Date} atime - Last Accessed time
* @property {String} ctimeStr - Creation time string
* @property {String} btimeStr - Creation time string
* @property {String} mtimeStr - Last Modified time string
* @property {String} atimeStr - Last Accessed time string
* @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 find
* @property {String} target.path - Resolved symlink target path
* @property {Boolean} target.broken - Whether or not the link is broken
* @property {String} type - Type of inode returned by find, single character from mode string
* @property {String} typeHuman - Type of inode returned by find, human readable string
* @property {String|null} linkType - Type of inode link is pointing to or null if not link
* @property {String|null} linkRawPath - Target of link or null if not link
* @property {String|null} linkRawPathHTML - HTML formatted target of link or null if not link
* @property {Boolean} linkBroken - True if is symlink and broken, otherwise false
* @property {String} resolvedPath - Path to file or path to target if symlink
* @property {String} resolvedType - Type of file or type of target if symlink, single character from mode string
* @property {String} resolvedTypeHuman - Type of file or type of target if symlink, human readable string
* @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

View File

@ -68,21 +68,21 @@
<div class="grow overflow-hidden">
<DirectoryView :host="pathHistory.current()?.host" :path="pathHistory.current()?.path"
:searchFilterRegExp="searchFilterRegExp" @cd="path => cd({ path })" @edit="openEditor"
@entryAction="handleEntryAction"
@browserAction="handleAction"
ref="directoryViewRef" />
</div>
</div>
</div>
<ModalPopup :showModal="openPrompt.show" :headerText="openPrompt.entry?.name ?? 'NULL'" @close="() => openPrompt.close()">
<ModalPopup :showModal="openFilePromptModal.show" :headerText="openFilePromptModal.entry?.name ?? 'NULL'" @close="() => openFilePromptModal.close()">
What would you like to do with this file?
<template #footer>
<button type="button" class="btn btn-secondary" @click="() => openPrompt.close()">
<button type="button" class="btn btn-secondary" @click="() => openFilePromptModal.close()">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="() => openEditor(openPrompt.entry.path)">
<button type="button" class="btn btn-primary" @click="() => openFilePromptModal.action('edit') ">
Open for editing
</button>
<button type="button" class="btn btn-primary">
<button type="button" class="btn btn-primary" @click="() => openFilePromptModal.action('download') ">
Download
</button>
</template>
@ -112,6 +112,7 @@ import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey,
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon, SunIcon, MoonIcon, EyeIcon, EyeOffIcon, ViewListIcon, ViewGridIcon } from '@heroicons/vue/solid';
import IconToggle from '../components/IconToggle.vue';
import ModalPopup from '../components/ModalPopup.vue';
import { fileDownload } from '@45drives/cockpit-helpers';
const encodePartial = (string) =>
encodeURIComponent(string)
@ -160,19 +161,23 @@ export default {
forwardHistoryDropdown.showDropdown = false;
}
});
const openPrompt = reactive({
const openFilePromptModal = reactive({
show: false,
entry: null,
resetTimeoutHandle: null,
open: (entry) => {
clearTimeout(openPrompt.resetTimeoutHandle);
openPrompt.entry = entry;
openPrompt.show = true;
clearTimeout(openFilePromptModal.resetTimeoutHandle);
openFilePromptModal.entry = entry;
openFilePromptModal.show = true;
},
close: () => {
openPrompt.show = false;
openPrompt.resetTimeoutHandle = setTimeout(() => openPrompt.resetTimeoutHandle = openPrompt.entry = null, 500);
openFilePromptModal.show = false;
openFilePromptModal.resetTimeoutHandle = setTimeout(() => openFilePromptModal.resetTimeoutHandle = openFilePromptModal.entry = null, 500);
},
action: (action) => {
handleAction(action, openFilePromptModal.entry);
openFilePromptModal.close();
}
});
const cd = ({ path, host }) => {
@ -193,25 +198,39 @@ export default {
cd({path: pathHistory.current().path + '/..'});
}
const openEditor = (path) => {
router.push(`/edit/${pathHistory.current().host}${encodePartial(path)}`);
const openEditor = ({ path, host }) => {
const newHost = host ?? (pathHistory.current().host);
const newPath = encodePartial(path ?? (pathHistory.current().path));
router.push(`/edit/${newHost}${newPath}`);
}
const download = ({ path, name, host }) => {
fileDownload(path, name, host);
console.log('download', `${host}:${path}`);
}
const openFilePrompt = (entry) => {
openFilePromptModal.open(entry);
}
const getSelected = () => directoryViewRef.value?.getSelected?.() ?? [];
const handleEntryAction = (action, entry, event) => {
const handleAction = (action, ...args) => {
switch (action) {
case 'cd':
cd({path: entry.path});
cd(...args);
break;
case 'edit':
openEditor({path: entry.path})
openEditor(...args);
break;
case 'openPrompt':
openPrompt.open(entry);
case 'openFilePrompt':
openFilePrompt(...args);
break;
case 'download':
download(...args);
break;
default:
console.error('Unknown entryAction:', action, entry);
console.error('Unknown browserAction:', action, args);
break;
}
}
@ -247,14 +266,16 @@ export default {
searchFilterRegExp,
backHistoryDropdown,
forwardHistoryDropdown,
openPrompt,
openFilePromptModal,
cd,
back,
forward,
up,
openEditor,
download,
openFilePrompt,
getSelected,
handleEntryAction,
handleAction,
}
},
components: {