clean up navigation with router

This commit is contained in:
joshuaboud 2022-05-18 14:28:24 -03:00
parent 523166d424
commit a716954e1f
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
8 changed files with 208 additions and 90 deletions

View File

@ -7,7 +7,7 @@
<component :is="icon" class="size-icon icon-default" />
<LinkIcon v-if="entry.type === 'symbolic link'" class="w-2 h-2 absolute right-0 bottom-0 text-default" />
</div>
<button v-if="directoryLike" @click.stop="() => showEntries = !showEntries">
<button v-if="directoryLike" @click.stop="toggleShowEntries">
<ChevronDownIcon v-if="!showEntries" class="size-icon icon-default" />
<ChevronUpIcon v-else class="size-icon icon-default" />
</button>
@ -107,6 +107,14 @@ export default {
return directoryViewRef.value?.getEntries?.();
}
const toggleShowEntries = () => {
emit('startProcessing');
nextTick(() => {
showEntries.value = !showEntries.value;
nextTick(() => emit('stopProcessing'));
})
}
watch(props.entry, () => {
if (props.entry.type === 'directory' || (props.entry.type === 'symbolic link' && props.entry.target?.type === 'directory')) {
icon.value = FolderIcon;
@ -125,6 +133,7 @@ export default {
directoryViewRef,
doubleClickCallback,
getEntries,
toggleShowEntries,
DirectoryEntryList,
nextTick,
}

View File

@ -90,30 +90,40 @@ export default {
}
const getEntries = async () => {
processingHandler.start();
const readLink = (target, cwd, symlinkStr) => {
return new Promise((resolve, reject) => {
const linkTargetRaw = symlinkStr.split(/\s*->\s*/)[1].trim().replace(/^['"]|['"]$/g, '');
Object.assign(target, {
rawPath: linkTargetRaw,
path: canonicalPath(linkTargetRaw.replace(/^(?!\/)/, `${cwd}/`)),
});
useSpawn(['stat', '-c', '%F', target.path], { superuser: 'try' }).promise()
.then(state => {
target.type = state.stdout.trim();
target.broken = false;
})
.catch(() => {
target.broken = true;
})
.finally(resolve);
})
if (!props.path) {
return;
}
processingHandler.start();
const US = '\x1F';
const RS = '\x1E';
const processLinks = (linkTargets) => {
if (linkTargets.length === 0)
return null;
const callback = state => state.stdout
.trim()
.split('\n')
.filter(record => record)
.map((record, index) => {
if (record.includes(US)) {
const [type, mode] = record.split(US);
linkTargets[index].type = type;
linkTargets[index].mode = mode;
linkTargets[index].broken = false;
} else { // error
linkTargets[index].broken = true;
}
});
return new Promise((resolve, reject) =>
useSpawn(['stat', `--printf=%F${US}%f\n`, ...linkTargets.map(target => target.path)], { superuser: 'try', err: 'out' }).promise()
.then(callback)
.catch(callback)
.finally(resolve)
)
}
try {
const cwd = props.path;
const procs = [];
let tmpEntries;
procs.push(...entryRefs.value.filter(entryRef => entryRef.showEntries).map(entryRef => entryRef.getEntries()));
const entryNames =
(await useSpawn(['dir', '--almost-all', '--dereference-command-line-symlink-to-dir', '--quoting-style=c', '-1', cwd], { superuser: 'try' }).promise()).stdout
@ -123,7 +133,7 @@ export default {
try {
return JSON.parse(escaped);
} catch (error) {
notifications.constructNotification("Failed to parse file name", `${errorStringHTML(error)}\ncaused by ${escaped}`, 'error');
notifications.value.constructNotification("Failed to parse file name", `${errorStringHTML(error)}\ncaused by ${escaped}`, 'error');
return null;
}
})
@ -141,7 +151,7 @@ export default {
'%F', // type
'%N', // quoted name with symlink
]
entries.value =
tmpEntries =
entryNames.length
? (await useSpawn(['stat', `--printf=${fields.join(US)}${RS}`, ...entryNames], { superuser: 'try', directory: cwd }).promise()).stdout
.split(RS)
@ -154,7 +164,7 @@ export default {
mode = parseInt(mode, 16);
const entry = reactive({
name,
path: canonicalPath(`/${cwd}/${name}`),
path: `${cwd}/${name}`.replace(/\/+/g, '/'),
mode,
modeStr,
size,
@ -167,8 +177,10 @@ export default {
type,
target: {},
});
if (type === 'symbolic link')
procs.push(readLink(entry.target, cwd, symlinkStr));
if (type === 'symbolic link') {
entry.target.rawPath = symlinkStr.split(/\s*->\s*/)[1].trim().replace(/^['"]|['"]$/g, '');
entry.target.path = entry.target.rawPath.replace(/^(?!\/)/, `${cwd}/`);
}
return entry;
} catch (error) {
console.error(errorString(error));
@ -176,10 +188,13 @@ export default {
}
}).filter(entry => entry !== null)
: [];
procs.push(processLinks(tmpEntries.filter(entry => entry.type === 'symbolic link').map(entry => entry.target)));
processingHandler.start();
console.log("resolving", procs.length, 'symlinks');
return Promise.all(procs)
.then(() => {
if (props.path !== cwd)
return;
entries.value = [...tmpEntries];
emitStats();
sortEntries();
})
@ -223,7 +238,11 @@ export default {
watch(() => props.sortCallback, sortEntries);
watch(() => settings.directoryView?.separateDirs, sortEntries);
watch(() => props.path, getEntries, { immediate: true });
watch(() => props.path, (current, old) => {
if (current === old)
return;
getEntries();
}, { immediate: true });
return {
settings,

View File

@ -5,7 +5,7 @@
<template v-for="segment, index in pathArr" :key="index">
<ChevronRightIcon v-if="index > 0" class="size-icon icon-default" />
<button
@click.prevent.stop="$emit('cd', canonicalPath(pathArr.slice(0, index + 1).join('/')))"
@click.prevent.stop="$emit('cd', `/${pathArr.slice(1, index + 1).join('/')}`)"
class="p-2 hover:bg-accent rounded-lg cursor-pointer"
>{{ segment }}</button>
</template>

View File

@ -1,16 +1,17 @@
import { createApp, reactive } from 'vue';
import App from './App.vue';
import { FIFO } from '@45drives/cockpit-helpers';
import { errorString, FIFO } from '@45drives/cockpit-helpers';
import '@45drives/cockpit-css/src/index.css';
import { useSpawn, errorStringHTML } from '@45drives/cockpit-helpers';
import router from './router';
const notificationFIFO = reactive(new FIFO());
const errorHandler = (error) => {
const errorHandler = (error, title = "System Error") => {
console.error(error);
const notificationObj = {
title: "System Error",
title,
body: "",
show: true,
timeout: 10000,
@ -32,6 +33,33 @@ const errorHandler = (error) => {
throw error;
}
router.beforeEach(async (to, from) => {
if (to.name === 'browse') {
if (!to.params.path)
return "/browse/"; // force / for opening root
try {
let realPath = (await useSpawn(['realpath', '--canonicalize-existing', to.params.path], { superuser: 'try' }).promise()).stdout.trim();
if (to.params.path !== realPath)
return `/browse${realPath}`;
try {
await useSpawn(['test', '-r', to.params.path, '-a', '-x', to.params.path], { superuser: 'try' }).promise();
} catch {
throw new Error(`Permission denied for ${to.params.path}`);
}
} catch (error) {
if (from.name === undefined)
return { name: 'errorRedirect', query: { title: "Error opening path", message: errorString(error) } }
errorHandler(errorStringHTML(error), "Failed to open path");
return false;
}
}
if (cockpit.location.href !== to.fullPath.replace(/\/$/, '') && to.name !== 'errorRedirect') {
cockpit.location.replace(to.fullPath);
return false; // avoid double render from router change and cockpit path change
}
return true;
})
const app = createApp(App, { notificationFIFO }).use(router);
app.config.errorHandler = (error) => errorHandler(error);

View File

@ -1,21 +1,27 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { lastPathStorageKey } from '../keys';
const routes = [
{
path: '/browse:path(.+)',
path: '/browse:path(/.*)?',
name: 'browse',
component: () => import('../views/Browser.vue'),
},
{
path: '/edit/:path(.*)',
path: '/edit:path(/.+)',
name: 'edit',
component: () => import('../views/Editor.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'redirectToBrowse',
path: '/errorRedirect',
name: 'errorRedirect',
component: () => import('../views/ErrorRedirect.vue'),
props: route => ({ title: route.query.title, message: route.query.message }),
},
{
path: '/:pathMatch(.*)',
name: 'notFound',
redirect: route => ({ name: 'errorRedirect', query: { title: 'Not found', message: `${route.href} is not a valid location.` }}),
}
];
const router = createRouter({
@ -23,14 +29,4 @@ const router = createRouter({
routes,
});
router.beforeEach((to, from, next) => {
if (to.name === 'redirectToBrowse') {
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
next(`/browse${lastLocation}`);
cockpit.location.go(`/browse${lastLocation}`);
} else {
next();
}
})
export default router;

View File

@ -4,7 +4,7 @@
<div
class="grid grid-cols-[auto_1fr] grid-rows-[1fr 1fr] md:grid-cols-[auto_3fr_1fr] md:grid-row-[1fr] items-stretch divide-x divide-y divide-default"
>
<div class="button-group-row p-1 md:px-4 md:py-2">
<div class="button-group-row p-1 md:px-4 md:py-2 border border-default">
<button
class="p-2 rounded-lg hover:bg-accent relative"
:disabled="!pathHistory.backAllowed()"
@ -53,7 +53,11 @@
>{{ item }}</div>
</div>
</button>
<button class="p-2 rounded-lg hover:bg-accent" @click="up()">
<button
class="p-2 rounded-lg hover:bg-accent"
@click="up()"
:disabled="pathHistory.current() === '/'"
>
<ArrowUpIcon class="size-icon icon-default" />
</button>
<button class="p-2 rounded-lg hover:bg-accent" @click="directoryViewRef.getEntries()">
@ -64,7 +68,7 @@
<div
class="p-1 md:px-4 md:py-2 col-start-1 col-end-3 row-start-2 row-end-3 md:col-start-auto md:col-end-auto md:row-start-auto md:row-end-auto"
>
<PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath, newPath !== pathHistory.current())" />
<PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath)" />
</div>
<div class="p-1 md:px-4 md:py-2">
@ -83,10 +87,10 @@
</div>
<div class="grow overflow-hidden">
<DirectoryView
:path="pathHistory.current() ?? '/'"
:path="pathHistory.current()"
:searchFilterRegExp="searchFilterRegExp"
@cd="newPath => cd(newPath)"
@edit="(...args) => console.log('edit', ...args)"
@edit="openEditor"
@updateStats="stats => $emit('updateFooterText', `${stats.files} file${stats.files === 1 ? '' : 's'}, ${stats.dirs} director${stats.dirs === 1 ? 'y' : 'ies'}`)"
ref="directoryViewRef"
/>
@ -97,11 +101,10 @@
<script>
import { inject, ref, reactive, watch, nextTick } from 'vue';
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import DirectoryView from "../components/DirectoryView.vue";
import { errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
import { useSpawn, errorString, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
import PathBreadCrumbs from '../components/PathBreadCrumbs.vue';
import { checkIfExists, checkIfAllowed } from '../mode';
import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey, settingsInjectionKey } from '../keys';
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon } from '@heroicons/vue/solid';
@ -110,6 +113,7 @@ export default {
const settings = inject(settingsInjectionKey);
const notifications = inject(notificationsInjectionKey);
const route = useRoute();
const router = useRouter();
const pathHistory = inject(pathHistoryInjectionKey);
const directoryViewRef = ref();
const searchFilterStr = ref("");
@ -143,66 +147,63 @@ export default {
}
});
const cd = (newPath, saveHistory = true) => {
localStorage.setItem(lastPathStorageKey, newPath);
if (saveHistory)
pathHistory.push(newPath);
cockpit.location.go(`/browse${newPath}`);
const cd = (newPath) => {
router.push(`/browse${newPath}`);
};
const back = (saveHistory = false) => {
cd(pathHistory.back() ?? '/', saveHistory);
const back = () => {
cd(pathHistory.back() ?? '/');
}
const forward = (saveHistory = false) => {
cd(pathHistory.forward() ?? '/', saveHistory);
const forward = () => {
cd(pathHistory.forward() ?? '/');
}
const up = (saveHistory = true) => {
cd(canonicalPath((pathHistory.current() ?? "") + '/..'), saveHistory);
const up = () => {
cd((pathHistory.current() ?? "") + '/..');
}
const openEditor = (path) => {
router.push(`/edit${path}`);
}
watch(searchFilterStr, () => {
searchFilterRegExp.value = new RegExp(
`^${
searchFilterStr.value
.replace(/[.+^${}()|[\]]|\\(?![*?])/g, '\\$&') // escape special chars, \\ only if not before * or ?
.replace(/(?<!\\)\*/g, '.*') // replace * with .* if not escaped
.replace(/(?<!\\)\?/g, '.') // replace ? with . if not escaped
`^${searchFilterStr.value
.replace(/[.+^${}()|[\]]|\\(?![*?])/g, '\\$&') // escape special chars, \\ only if not before * or ?
.replace(/(?<!\\)\*/g, '.*') // replace * with .* if not escaped
.replace(/(?<!\\)\?/g, '.') // replace ? with . if not escaped
}`
);
}, { immediate: true });
watch(() => route.params.path, async () => {
if (!route.params.path)
return cockpit.location.go('/browse/');
const tmpPath = canonicalPath(route.params.path);
if (pathHistory.current() !== tmpPath) {
pathHistory.push(tmpPath);
watch(() => route.params.path, async (current, last) => {
if (!last) {
console.log("First watch execute", last);
}
if (route.name !== 'browse' || current === last)
return;
try {
let badPath = false;
if (!await checkIfExists(tmpPath)) {
notifications.value.constructNotification("Failed to open path", `${tmpPath} does not exist.`, 'error');
badPath = true;
} else if (!await checkIfAllowed(tmpPath, true)) {
notifications.value.constructNotification("Failed to open path", `Permission denied for ${tmpPath}`, 'denied');
badPath = true;
const tmpPath = route.params.path;
// let realPath = (await useSpawn(['realpath', '--canonicalize-existing', tmpPath], { superuser: 'try' }).promise()).stdout.trim();
// if (tmpPath !== realPath)
// return cd(realPath);
try {
await useSpawn(['test', '-r', tmpPath, '-a', '-x', tmpPath], { superuser: 'try' }).promise();
} catch (error) {
console.error(error);
throw new Error(`Permission denied for ${tmpPath}`);
}
if (badPath) {
if (pathHistory.backAllowed())
back();
else
up(false);
} else {
localStorage.setItem(lastPathStorageKey, tmpPath);
localStorage.setItem(lastPathStorageKey, tmpPath);
if (pathHistory.current() !== tmpPath) {
pathHistory.push(tmpPath); // updates actual view
}
} catch (error) {
notifications.value.constructNotification("Failed to open path", errorStringHTML(error), 'error');
if (pathHistory.backAllowed())
back();
else
up(false);
up();
}
}, { immediate: true });
@ -219,6 +220,7 @@ export default {
back,
forward,
up,
openEditor,
}
},
components: {

View File

@ -0,0 +1,22 @@
<template>
<div class="grow">Editing {{ path }}</div>
</template>
<script>
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
export default {
setup() {
const path = ref("");
const route = useRoute();
watch(() => route.params.path, (filePath) => path.value = filePath, { immediate: true });
return {
path,
}
}
}
</script>

View File

@ -0,0 +1,42 @@
<template>
<div class="grow flex flex-col items-center justify-center gap-10">
<div class="text-header">{{title}}</div>
<div class="text-muted">{{message}}</div>
<div class="text-muted">Redirecting in {{counter}}...</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { lastPathStorageKey } from '../keys';
const props = defineProps({
message: {
type: String,
required: false,
default: "A system error has occured.",
},
title: {
type: String,
required: false,
default: "Error",
}
})
const router = useRouter();
const counter = ref(3);
const countdown = () => {
counter.value--;
if (counter.value > 0)
setTimeout(countdown, 1000);
else {
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
router.push(`/browse${lastLocation}`);
}
}
setTimeout(countdown, 1000);
</script>