Files
spacedrive/interface/app/$libraryId/Explorer/FilePath/RenameTextBox.tsx
Ericson "Fogo" Soares 28d106a2d5 [ENG-862, ENG-921] Ephemeral locations (#1092)
* Some initial drafts

* Finising the first draft on non-indexed locations

* Minor tweaks

* Fix warnings

* Adding date_created and date_modified to non indexed path entries

* Add id and path properties to NonIndexedPathItem

* Working ephemeral location (hardcoded home for now)

* Fix UI for ephemeral locations

* Fix windows

* Passing ephemeral thumbnails to thumbnails remover

* Indexing rules for ephemeral paths walking

* Animate Location button when path text overflow it's size

* Fix Linux not showing all volumes

* Fix Linux
 - Add some missing no_os_protected rules for macOS
 - Improve ephemeral location names

* Remove unecessary import

* Fix Mobile

* Improve resizing behaviour for ephemeral location topbar path button
 - Improve Search View (Replace custom empty component with Explorer's emptyNotice )
 - Improve how TopBar children positioning

* Hide EphemeralSection if there is no volume or home
 - Disable Ephemeral topbar path button animation when text is not overflowing

* minor fixes

* Introducing ordering for ephemeral paths

* TS Format

* Ephemeral locations UI fixes
 - Fix indexed Locations having no metadata
 - Remove date indexed/accessed options for sorting Ephemeral locations
 - Remove empty three dots from SideBar element when no settings is linked

* Add tooltip to add location button in ephemeral locations

* Fix indexed Locations selecting other folder/files in Ephemeral location

* Minor fixes

* Fix app breaking due to wrong logic to get item full path in Explorer

* Revert some recent changes to Thumb.tsx

* Fix original not loading for overview items
 - Fix QuickPreview name broken for overview items

* Improve imports

* Revert replace useEffect with useLayoutEffect for locked logic in ListView
It was causing the component to full reload when clicking a header to sort per column

* Changes from feedback

* Hide some unused Inspector metadata fields on NonIndexedPaths
 - Merge formatDate functions while retaining original behaviour

* Use tauri api for getting user home

* Change ThumbType to a string enum to allow for string comparisons

* Improve ObjectKind typing

---------

Co-authored-by: Vítor Vasconcellos <vasconcellos.dev@gmail.com>
Co-authored-by: Oscar Beaumont <oscar@otbeaumont.me>
2023-08-23 17:26:07 +00:00

300 lines
7.2 KiB
TypeScript

import clsx from 'clsx';
import {
type ComponentProps,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import { useKey } from 'rooks';
import { useLibraryMutation, useRspcLibraryContext } from '@sd/client';
import { Tooltip } from '~/../packages/ui/src';
import { showAlertDialog } from '~/components';
import { useIsTextTruncated, useOperatingSystem } from '~/hooks';
import { useExplorerViewContext } from '../ViewContext';
type Props = ComponentProps<'div'> & {
itemId?: null | number;
locationId: number | null;
text: string | null;
activeClassName?: string;
disabled?: boolean;
renameHandler: (name: string) => Promise<void>;
};
export const RenameTextBoxBase = forwardRef<HTMLDivElement | null, Props>(
(
{ className, activeClassName, disabled, itemId, locationId, text, renameHandler, ...props },
_ref
) => {
const explorerView = useExplorerViewContext();
const os = useOperatingSystem();
const [allowRename, setAllowRename] = useState(false);
const [renamable, setRenamable] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(_ref, () => ref.current);
// Highlight file name up to extension or
// fully if it's a directory or has no extension
const highlightText = useCallback(() => {
if (ref?.current) {
const range = document.createRange();
const node = ref.current.firstChild;
if (!node) return;
const endRange = text?.lastIndexOf('.');
range.setStart(node, 0);
range.setEnd(node, endRange && endRange !== -1 ? endRange : text?.length || 0);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}, [text]);
// Blur field
function blur() {
if (ref?.current) {
ref.current.blur();
setAllowRename(false);
}
}
// Reset to original file name
function reset() {
if (ref?.current) {
ref.current.innerText = text || '';
}
}
async function handleRename() {
if (!ref?.current) return;
const newName = ref?.current.innerText.trim();
if (!(newName && locationId)) return reset();
const oldName = text;
if (!oldName || newName === oldName) return;
await renameHandler(newName);
}
// Handle keydown events
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
switch (e.key) {
case 'Tab':
e.preventDefault();
blur();
break;
case 'Escape':
reset();
blur();
break;
case 'z':
if (os === 'macOS' ? e.metaKey : e.ctrlKey) {
reset();
highlightText();
}
break;
}
}
//this is to determine if file name is truncated
const isTruncated = useIsTextTruncated(ref, text);
// Focus and highlight when renaming is allowed
useEffect(() => {
if (allowRename) {
setTimeout(() => {
if (ref?.current) {
ref.current.focus();
highlightText();
}
});
}
}, [allowRename, explorerView, highlightText]);
// Handle renaming when triggered from outside
useEffect(() => {
if (!disabled) {
if (explorerView.isRenaming && !allowRename) setAllowRename(true);
else if (!explorerView.isRenaming && allowRename) setAllowRename(false);
}
}, [explorerView.isRenaming, disabled, allowRename]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref?.current && !ref.current.contains(event.target as Node)) {
blur();
}
}
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
}, [ref]);
// Rename or blur on Enter key
useKey('Enter', (e) => {
e.preventDefault();
if (allowRename) blur();
else if (!disabled) {
setAllowRename(true);
explorerView.setIsRenaming(true);
}
});
useEffect(() => {
const elem = ref.current;
const scroll = (e: WheelEvent) => {
if (allowRename) {
e.preventDefault();
if (elem) elem.scrollTop += e.deltaY;
}
};
elem?.addEventListener('wheel', scroll);
return () => elem?.removeEventListener('wheel', scroll);
}, [allowRename]);
return (
<Tooltip label={!isTruncated || allowRename ? null : text} asChild>
<div
ref={ref}
role="textbox"
contentEditable={allowRename}
suppressContentEditableWarning
className={clsx(
'cursor-default truncate rounded-md px-1.5 py-px text-xs text-ink',
allowRename && [
'whitespace-normal bg-app outline-none ring-2 ring-accent-deep',
activeClassName
],
className
)}
onDoubleClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.button === 0 && setRenamable(!disabled)}
onMouseUp={(e) => {
if (e.button === 0) {
if (renamable) {
setAllowRename(true);
explorerView.setIsRenaming(true);
}
setRenamable(false);
}
}}
onBlur={async () => {
await handleRename();
setAllowRename(false);
explorerView.setIsRenaming(false);
}}
onKeyDown={handleKeyDown}
{...props}
>
{text}
</div>
</Tooltip>
);
}
);
export const RenamePathTextBox = ({
isDir,
...props
}: Omit<Props, 'renameHandler'> & { isDir: boolean; extension?: string | null }) => {
const rspc = useRspcLibraryContext();
const ref = useRef<HTMLDivElement>(null);
const renameFile = useLibraryMutation(['files.renameFile'], {
onError: () => reset(),
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
});
// Reset to original file name
function reset() {
if (ref?.current) {
ref.current.innerText = props.text || '';
}
}
const fileName = isDir || !props.extension ? props.text : props.text + '.' + props.extension;
// Handle renaming
async function rename(newName: string) {
// TODO: Warn user on rename fails
if (!props.locationId || !props.itemId || newName === fileName) {
reset();
return;
}
try {
await renameFile.mutateAsync({
location_id: props.locationId,
kind: {
One: {
from_file_path_id: props.itemId,
to: newName
}
}
});
} catch (e) {
reset();
showAlertDialog({
title: 'Error',
value: `Could not rename ${fileName} to ${newName}, due to an error: ${e}`
});
}
}
return <RenameTextBoxBase {...props} text={fileName} renameHandler={rename} ref={ref} />;
};
export const RenameLocationTextBox = (props: Omit<Props, 'renameHandler'>) => {
const rspc = useRspcLibraryContext();
const ref = useRef<HTMLDivElement>(null);
const renameLocation = useLibraryMutation(['locations.update'], {
onError: () => reset(),
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
});
// Reset to original file name
function reset() {
if (ref?.current) {
ref.current.innerText = props.text || '';
}
}
// Handle renaming
async function rename(newName: string) {
if (!props.locationId) {
reset();
return;
}
try {
await renameLocation.mutateAsync({
id: props.locationId,
name: newName,
generate_preview_media: null,
sync_preview_media: null,
hidden: null,
indexer_rules_ids: []
});
} catch (e) {
reset();
showAlertDialog({
title: 'Error',
value: String(e)
});
}
}
return <RenameTextBoxBase {...props} renameHandler={rename} ref={ref} />;
};