mirror of
https://github.com/45Drives/cockpit-navigator.git
synced 2025-07-28 16:14:25 +02:00
clean up navigation with router
This commit is contained in:
parent
523166d424
commit
a716954e1f
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
42
navigator-vue/src/views/ErrorRedirect.vue
Normal file
42
navigator-vue/src/views/ErrorRedirect.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user