mirror of
https://github.com/45Drives/cockpit-navigator.git
synced 2025-07-29 16:45:13 +02:00
create/edit links, create dirs/files, edit dir/file names
This commit is contained in:
parent
d2deb01783
commit
075c1e7fc9
135
navigator/src/components/FileNameEditor.vue
Normal file
135
navigator/src/components/FileNameEditor.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<ModalPopup
|
||||
:showModal="show"
|
||||
:headerText="createNew ? `New ${({f: 'file', d: 'directory'})[createNew]}` : `Rename ${entry?.name}`"
|
||||
autoWidth
|
||||
:disableContinue="!valid"
|
||||
@cancel="() => $emit('hide')"
|
||||
@apply="apply"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="name"
|
||||
class="input-textlike w-full"
|
||||
placeholder="Name"
|
||||
@keypress.enter="apply"
|
||||
ref="inputRef"
|
||||
/>
|
||||
<div
|
||||
v-if="feedback"
|
||||
class="feedback-group"
|
||||
>
|
||||
<ExclamationCircleIcon class="size-icon icon-error" />
|
||||
<span class="text-feedback text-error">{{ feedback }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stderr"
|
||||
class="feedback-group"
|
||||
>
|
||||
<ExclamationCircleIcon class="size-icon icon-error" />
|
||||
<span
|
||||
class="text-feedback text-error"
|
||||
v-html="stderr"
|
||||
/>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useSpawn, errorStringHTML } from "@45drives/cockpit-helpers";
|
||||
import { ExclamationCircleIcon } from '@heroicons/vue/solid';
|
||||
import ModalPopup from './ModalPopup.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
show: Boolean,
|
||||
entry: Object,
|
||||
createNew: String,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const name = ref("");
|
||||
const valid = ref(false);
|
||||
const feedback = ref("");
|
||||
const stderr = ref("");
|
||||
const inputRef = ref();
|
||||
|
||||
const apply = async () => {
|
||||
if (!valid.value)
|
||||
return;
|
||||
stderr.value = "";
|
||||
try {
|
||||
if (!props.createNew) {
|
||||
await (useSpawn(['mv', '-nT', props.entry.path, props.entry.path.split('/').slice(0, -1).concat(name.value).join('/')], { superuser: 'try' }).promise());
|
||||
} else if (['f', 'd'].includes(props.createNew)) {
|
||||
const parentPath = props.entry.resolvedType === 'd' ? props.entry.resolvedPath : props.entry.path.split('/').slice(0, -1).join('/');
|
||||
const path = `${parentPath}/${name.value}`
|
||||
await useSpawn(['test', '!', '(', '-e', path, '-o', '-L', path, ')'], { superuser: 'try' }).promise().catch(() => { throw new Error('File exists') });
|
||||
if (props.createNew === 'f')
|
||||
await useSpawn(['touch', '-h', path], { superuser: 'try' }).promise();
|
||||
else
|
||||
await useSpawn(['mkdir', path], { superuser: 'try' }).promise();
|
||||
}
|
||||
|
||||
emit('hide');
|
||||
setTimeout(() => {
|
||||
name.value = feedback.value = "";
|
||||
valid.value = false;
|
||||
}, 500);
|
||||
} catch (state) {
|
||||
valid.value = false;
|
||||
stderr.value = errorStringHTML(state);
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => props.entry, () => props.createNew], () => {
|
||||
if (props.createNew)
|
||||
name.value = "";
|
||||
else
|
||||
name.value = props.entry?.name ?? "";
|
||||
}, { immediate: true });
|
||||
|
||||
watch(name, () => {
|
||||
let result = true;
|
||||
let feedbackArr = [];
|
||||
if (!name.value) {
|
||||
feedbackArr.push('Name cannot be empty');
|
||||
result = false;
|
||||
}
|
||||
if (name.value.includes('/')) {
|
||||
feedbackArr.push("Name cannot include '/'");
|
||||
result = false;
|
||||
}
|
||||
if (['.', '..'].includes(name.value)) {
|
||||
feedbackArr.push(`Name cannot be '${name.value}'`);
|
||||
result = false;
|
||||
}
|
||||
feedback.value = feedbackArr.join(', ');
|
||||
valid.value = result;
|
||||
});
|
||||
|
||||
watch(() => props.show, () => {
|
||||
if (props.show) {
|
||||
feedback.value = stderr.value = "";
|
||||
nextTick(() => inputRef.value.focus());
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
valid,
|
||||
feedback,
|
||||
stderr,
|
||||
inputRef,
|
||||
apply,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ExclamationCircleIcon,
|
||||
ModalPopup,
|
||||
},
|
||||
emits: [
|
||||
'hide',
|
||||
]
|
||||
}
|
||||
</script>
|
130
navigator/src/components/ModalPrompt.vue
Normal file
130
navigator/src/components/ModalPrompt.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<ModalPopup
|
||||
:showModal="show"
|
||||
:headerText="headerText"
|
||||
autoWidth
|
||||
:disableContinue="!valid"
|
||||
@cancel="cancel()"
|
||||
@apply="apply()"
|
||||
>
|
||||
<div
|
||||
v-for="input in inputs"
|
||||
:key="input.key"
|
||||
>
|
||||
<label
|
||||
v-if="input.label"
|
||||
class="block text-label"
|
||||
>{{ input.label }}</label>
|
||||
<input
|
||||
:class="[!['button', 'checkbox', 'color', 'file', 'radio', 'range'].includes(input.type) ? 'input-textlike w-full' : input.type == 'checkbox' ? 'input-checkbox' : '']"
|
||||
v-bind="{...input.props}"
|
||||
:type="input.type"
|
||||
:value="output[input.key] ?? input.default"
|
||||
:placeholder="input.placeholder"
|
||||
@input="(event) => { output[input.key] = event.target.value; validate(); }"
|
||||
/>
|
||||
<div
|
||||
v-if="input.feedback"
|
||||
class="feedback-group"
|
||||
>
|
||||
<ExclamationCircleIcon class="size-icon icon-error" />
|
||||
<span class="text-feedback text-error">{{ input.feedback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { ExclamationCircleIcon } from '@heroicons/vue/solid';
|
||||
import ModalPopup from './ModalPopup.vue';
|
||||
|
||||
export default {
|
||||
setup(props, { emit }) {
|
||||
const show = ref(false);
|
||||
const headerText = ref("");
|
||||
const inputs = ref([]);
|
||||
const output = ref({});
|
||||
const valid = ref(false);
|
||||
const apply = ref(() => null);
|
||||
const cancel = ref(() => null);
|
||||
|
||||
/**
|
||||
* Promise with method to add input to prompt
|
||||
* @typedef {Object} ModalPromptPromise
|
||||
* @property {function} addInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prompt user for input
|
||||
* @param {string} header - Text to display in popup header
|
||||
* @returns {ModalPromptPromise} - Resolves object with responses or null if cancelled
|
||||
*/
|
||||
const prompt = (header) => {
|
||||
headerText.value = header;
|
||||
output.value = {};
|
||||
inputs.value = [];
|
||||
valid.value = false;
|
||||
show.value = true;
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
apply.value = () => resolve(output.value);
|
||||
cancel.value = () => resolve(null);
|
||||
}).finally(() => show.value = false);
|
||||
/**
|
||||
* Callback to validate input, cannot be arrow function
|
||||
* @callback ModalPromptInputValidationCallback
|
||||
* @param {any} - value of input
|
||||
* @returns {boolean} - true if valid, false otherwise
|
||||
*/
|
||||
/**
|
||||
* Add an input to the prompt
|
||||
* @param {string} key - object key for result
|
||||
* @param {string} type - input tag type prop value
|
||||
* @param {string} label - label for input
|
||||
* @param {string} placeholder - input placeholder if applicable
|
||||
* @param {any} defaultValue - Default value of input
|
||||
* @param {ModalPromptInputValidationCallback} validate - Validation callback for input
|
||||
* @param {object} props - optional extra properties for input tag
|
||||
* @returns {ModalPromptPromise} - Resolves object with responses or null if cancelled
|
||||
*/
|
||||
const addInput = (key, type, label, placeholder, defaultValue, validate, props) => {
|
||||
const input = {
|
||||
key,
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
default: defaultValue,
|
||||
props,
|
||||
feedback: '',
|
||||
}
|
||||
output.value[key] = defaultValue;
|
||||
input.validate = validate?.bind(input),
|
||||
inputs.value.push(input);
|
||||
return prom;
|
||||
}
|
||||
prom.addInput = addInput;
|
||||
return prom;
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
valid.value = inputs.value.every(input => input.validate?.(output.value[input.key]) ?? true);
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
headerText,
|
||||
inputs,
|
||||
output,
|
||||
valid,
|
||||
apply,
|
||||
cancel,
|
||||
prompt,
|
||||
validate,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ModalPopup,
|
||||
ExclamationCircleIcon,
|
||||
}
|
||||
}
|
||||
</script>
|
@ -118,7 +118,7 @@
|
||||
</div>
|
||||
<ModalPopup
|
||||
:showModal="openFilePromptModal.show"
|
||||
:headerText="openFilePromptModal.entry?.name ?? 'NULL'"
|
||||
:headerText="openFilePromptModal.entry?.linkRawPath || (openFilePromptModal.entry?.name ?? 'NULL')"
|
||||
autoWidth
|
||||
@close="() => openFilePromptModal.close()"
|
||||
>
|
||||
@ -133,42 +133,62 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
class="btn btn-primary flex items-center gap-1"
|
||||
@click="() => openFilePromptModal.action('editPermissions')"
|
||||
>
|
||||
Edit permissions
|
||||
<span>Edit permissions</span>
|
||||
<KeyIcon class="size-icon text-default" />
|
||||
</button>
|
||||
<button
|
||||
v-if="openFilePromptModal.entry?.resolvedType === 'f'"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
class="btn btn-primary flex items-center gap-1"
|
||||
@click="() => openFilePromptModal.action('edit')"
|
||||
>
|
||||
Open for editing
|
||||
<span>Open for editing</span>
|
||||
<PencilAltIcon class="size-icon text-default" />
|
||||
</button>
|
||||
<button
|
||||
v-if="openFilePromptModal.entry?.resolvedType === 'f'"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
class="btn btn-primary flex items-center gap-1"
|
||||
@click="() => openFilePromptModal.action('download')"
|
||||
>
|
||||
Download
|
||||
<span>Download</span>
|
||||
<DownloadIcon class="size-icon text-default" />
|
||||
</button>
|
||||
</template>
|
||||
</ModalPopup>
|
||||
<ModalPopup
|
||||
:showModal="confirm.show"
|
||||
:headerText="confirm.header"
|
||||
autoWidth
|
||||
:applyDangerous="confirm.dangerous"
|
||||
@apply="confirm.resolve(true)"
|
||||
@close="confirm.close"
|
||||
>
|
||||
<div class="whitespace-pre">{{ confirm.body }}</div>
|
||||
</ModalPopup>
|
||||
<FilePermissions
|
||||
:show="filePermissions.show"
|
||||
:entry="filePermissions.entry"
|
||||
@hide="filePermissions.close"
|
||||
/>
|
||||
<FileNameEditor
|
||||
:show="nameEditor.show"
|
||||
:entry="nameEditor.entry"
|
||||
:createNew="nameEditor.createNew"
|
||||
@hide="nameEditor.close"
|
||||
/>
|
||||
<ContextMenu
|
||||
:show="contextMenu.show"
|
||||
:selection="contextMenu.selection"
|
||||
:event="contextMenu.event"
|
||||
:currentDirEntry="{...pathHistory.current(), name: `Current directory (${pathHistory.current().path.split('/').pop()})`}"
|
||||
:currentDirEntry="{ ...pathHistory.current(), name: `Current directory (${pathHistory.current().path.split('/').pop()})` }"
|
||||
@browserAction="handleAction"
|
||||
@hide="contextMenu.close"
|
||||
/>
|
||||
<ModalPrompt ref="modalPromptRef" />
|
||||
<Teleport to="#footer-buttons">
|
||||
<IconToggle
|
||||
v-model="darkMode"
|
||||
@ -220,12 +240,30 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import DirectoryView from "../components/DirectoryView.vue";
|
||||
import PathBreadCrumbs from '../components/PathBreadCrumbs.vue';
|
||||
import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey, settingsInjectionKey } from '../keys';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon, SunIcon, MoonIcon, EyeIcon, EyeOffIcon, ViewListIcon, ViewGridIcon } from '@heroicons/vue/solid';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
RefreshIcon,
|
||||
ChevronDownIcon,
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
KeyIcon,
|
||||
PencilAltIcon,
|
||||
DownloadIcon,
|
||||
} from '@heroicons/vue/solid';
|
||||
import IconToggle from '../components/IconToggle.vue';
|
||||
import ModalPopup from '../components/ModalPopup.vue';
|
||||
import { fileDownload } from '@45drives/cockpit-helpers';
|
||||
import { fileDownload, useSpawn, errorStringHTML } from '@45drives/cockpit-helpers';
|
||||
import FilePermissions from '../components/FilePermissions.vue';
|
||||
import FileNameEditor from '../components/FileNameEditor.vue';
|
||||
import ContextMenu from '../components/ContextMenu.vue';
|
||||
import ModalPrompt from '../components/ModalPrompt.vue';
|
||||
|
||||
const encodePartial = (string) =>
|
||||
encodeURIComponent(string)
|
||||
@ -292,6 +330,36 @@ export default {
|
||||
openFilePromptModal.close();
|
||||
}
|
||||
});
|
||||
const confirm = reactive({
|
||||
show: false,
|
||||
header: "",
|
||||
body: "",
|
||||
dangerous: false,
|
||||
resolve: () => null,
|
||||
reject: () => null,
|
||||
resetTimeoutHandle: null,
|
||||
ask: (header, body = "", dangerous = false) => {
|
||||
clearTimeout(confirm.resetTimeoutHandle);
|
||||
confirm.header = header;
|
||||
confirm.body = body;
|
||||
confirm.dangerous = dangerous;
|
||||
confirm.show = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
confirm.resolve = resolve;
|
||||
confirm.reject = reject;
|
||||
});
|
||||
},
|
||||
close: () => {
|
||||
confirm.show = false;
|
||||
confirm.resolve(false);
|
||||
confirm.resetTimeoutHandle = setTimeout(() => {
|
||||
confirm.resetTimeoutHandle = null;
|
||||
confirm.header = confirm.body = "";
|
||||
confirm.dangerous = false;
|
||||
confirm.resolve = confirm.reject = () => null;
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
const filePermissions = reactive({
|
||||
show: false,
|
||||
entry: null,
|
||||
@ -306,6 +374,24 @@ export default {
|
||||
filePermissions.resetTimeoutHandle = setTimeout(() => filePermissions.resetTimeoutHandle = filePermissions.entry = null, 500);
|
||||
},
|
||||
});
|
||||
const nameEditor = reactive({
|
||||
show: false,
|
||||
entry: null,
|
||||
createNew: null,
|
||||
resetTimeoutHandle: null,
|
||||
open: (entry, createNew) => {
|
||||
clearTimeout(nameEditor.resetTimeoutHandle);
|
||||
nameEditor.entry = entry;
|
||||
nameEditor.createNew = createNew ?? null;
|
||||
nameEditor.show = true;
|
||||
},
|
||||
close: () => {
|
||||
nameEditor.show = false;
|
||||
nameEditor.resetTimeoutHandle = setTimeout(() => {
|
||||
nameEditor.resetTimeoutHandle = nameEditor.entry = nameEditor.createNew = null;
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
const contextMenu = reactive({
|
||||
show: false,
|
||||
selection: [],
|
||||
@ -325,6 +411,7 @@ export default {
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
const modalPromptRef = ref();
|
||||
|
||||
const cd = ({ path, host }) => {
|
||||
const newHost = host ?? (pathHistory.current().host);
|
||||
@ -356,10 +443,90 @@ export default {
|
||||
let { path, name, host } = items[0];
|
||||
fileDownload(path, name, host);
|
||||
}
|
||||
// TODO: mutlifile & directory downloads
|
||||
}
|
||||
|
||||
const deleteItems = async (selection) => {
|
||||
const items = [].concat(selection); // forces to be array
|
||||
if (items.length === 0)
|
||||
return;
|
||||
if (!await confirm.ask(`Permanently delete ${items.length} item${items.length > 1 ? 's' : ''}?`, items.map(i => i.path).join('\n'), true))
|
||||
return;
|
||||
try {
|
||||
await useSpawn(['rm', '-rf', '--', ...items.map(i => i.path)]).promise();
|
||||
} catch (state) {
|
||||
notifications.value.constructNotification("Failed to remove file(s)", errorStringHTML(state), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const getSelected = () => directoryViewRef.value?.getSelected?.() ?? [];
|
||||
|
||||
const cwdAsEntryObj = () => {
|
||||
const obj = { ...pathHistory.current() };
|
||||
obj.name = obj.path.split('/').pop();
|
||||
obj.resolvedPath = obj.path;
|
||||
obj.type = obj.resolvedType = 'd';
|
||||
return obj;
|
||||
}
|
||||
|
||||
const createLink = async (parentEntry) => {
|
||||
const result = await modalPromptRef.value.prompt("Create link")
|
||||
.addInput(
|
||||
'linkName',
|
||||
'text',
|
||||
'Name',
|
||||
'Name',
|
||||
'',
|
||||
function (name) {
|
||||
let result = true;
|
||||
let feedbackArr = [];
|
||||
if (!name) {
|
||||
feedbackArr.push('Name cannot be empty');
|
||||
result = false;
|
||||
}
|
||||
if (name && name.includes('/')) {
|
||||
feedbackArr.push("Name cannot include '/'");
|
||||
result = false;
|
||||
}
|
||||
if (['.', '..'].includes(name)) {
|
||||
feedbackArr.push(`Name cannot be '${name}'`);
|
||||
result = false;
|
||||
}
|
||||
this.feedback = feedbackArr.join(', ');
|
||||
return result;
|
||||
}
|
||||
)
|
||||
.addInput(
|
||||
'linkTarget',
|
||||
'text',
|
||||
'Link target',
|
||||
'Target path',
|
||||
parentEntry.resolvedType !== 'd' ? parentEntry.path : ''
|
||||
);
|
||||
if (!result)
|
||||
return; // cancelled
|
||||
console.log(result);
|
||||
try {
|
||||
const parentPath = parentEntry.resolvedType === 'd' ? parentEntry.resolvedPath : parentEntry.path.split('/').slice(0, -1).join('/');
|
||||
const path = `${parentPath}/${result.linkName}`
|
||||
await useSpawn(['test', '!', '-e', path], { superuser: 'try' }).promise().catch(() => { throw new Error('File exists') });
|
||||
await useSpawn(['ln', '-snT', result.linkTarget, path], { superuser: 'try' }).promise();
|
||||
} catch (state) {
|
||||
notifications.value.constructNotification("Failed to create link", errorStringHTML(state), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const editLink = async (linkEntry) => {
|
||||
const { path, linkRawPath, name } = linkEntry;
|
||||
const { newTarget } = await modalPromptRef.value.prompt(`Edit link target for ${name}`)
|
||||
.addInput('newTarget', 'text', 'Link target', 'path/to/target', linkRawPath);
|
||||
try {
|
||||
await useSpawn(['ln', '-snfT', newTarget, path], { superuser: 'try' }).promise();
|
||||
} catch (state) {
|
||||
notifications.value.constructNotification("Failed to edit link", errorStringHTML(state), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = (action, ...args) => {
|
||||
switch (action) {
|
||||
case 'cd':
|
||||
@ -389,6 +556,24 @@ export default {
|
||||
case 'up':
|
||||
up();
|
||||
break;
|
||||
case 'rename':
|
||||
nameEditor.open(args[0], null);
|
||||
break;
|
||||
case 'createFile':
|
||||
nameEditor.open(args[0] ?? cwdAsEntryObj(), 'f');
|
||||
break;
|
||||
case 'createLink':
|
||||
createLink(args[0] ?? cwdAsEntryObj());
|
||||
break;
|
||||
case 'createDirectory':
|
||||
nameEditor.open(args[0] ?? cwdAsEntryObj(), 'd');
|
||||
break;
|
||||
case 'editLink':
|
||||
editLink(args[0]);
|
||||
break;
|
||||
case 'delete':
|
||||
deleteItems(...args);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown browserAction:', action, args);
|
||||
break;
|
||||
@ -427,8 +612,11 @@ export default {
|
||||
backHistoryDropdown,
|
||||
forwardHistoryDropdown,
|
||||
openFilePromptModal,
|
||||
confirm,
|
||||
filePermissions,
|
||||
nameEditor,
|
||||
contextMenu,
|
||||
modalPromptRef,
|
||||
cd,
|
||||
back,
|
||||
forward,
|
||||
@ -457,7 +645,12 @@ export default {
|
||||
ViewGridIcon,
|
||||
ModalPopup,
|
||||
FilePermissions,
|
||||
FileNameEditor,
|
||||
ContextMenu,
|
||||
KeyIcon,
|
||||
PencilAltIcon,
|
||||
DownloadIcon,
|
||||
ModalPrompt,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
Loading…
x
Reference in New Issue
Block a user