Skip to content

Commit ba772fe

Browse files
committed
fix: Prevent overwriting files and folders with existing content
https://harperdb.atlassian.net/browse/STUDIO-545
1 parent e5e5be3 commit ba772fe

File tree

7 files changed

+72
-33
lines changed

7 files changed

+72
-33
lines changed

src/features/instance/applications/components/NewApplication/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@ export function NewApplication() {
5050
path: [`applicationName`],
5151
message: 'Please name your application!',
5252
});
53+
} else if (rootEntries.find(rootEntry => rootEntry.name === data.applicationName)) {
54+
ctx.addIssue({
55+
code: 'custom',
56+
path: [`applicationName`],
57+
message: 'That application name is already in use!',
58+
});
5359
}
54-
}, [defaultApplicationName]);
60+
}, [defaultApplicationName, rootEntries]);
5561

5662
const methods = useForm({
5763
resolver: zodResolver(NewApplicationSchema.superRefine(refineZod)),

src/features/instance/applications/context/EditorViewContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { FileEntry } from './fileEntry';
88
export type EditorViewContextValue = {
99
rootEntries: Array<DirectoryEntry | FileEntry>;
1010
reloadRootEntries: () => Promise<APIDirectoryEntry>;
11+
entryExists: (path: string) => boolean;
1112

1213
openedEntry: DirectoryEntry | FileEntry | undefined;
1314
setOpenedEntry: (entry: DirectoryEntry | FileEntry | undefined) => void;

src/features/instance/applications/context/EditorViewProvider.tsx

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,28 +38,25 @@ export function EditorViewProvider({ children }: PropsWithChildren) {
3838
const queryClient = useQueryClient();
3939
const { open }: { open?: string } = useSearch({ strict: false });
4040

41-
const reloadRootEntries = useCallback(async () =>
42-
queryClient.fetchQuery<APIDirectoryEntry>({
43-
queryKey: [instanceParams.entityId, 'get_components'],
44-
networkMode: 'online',
45-
}), [queryClient, instanceParams]);
46-
4741
/*
4842
Create our structured view from the relational API data.
4943
*/
5044
const { data: apiComponents } = useSuspenseQuery(getComponentsQueryOptions(instanceParams));
51-
const rootEntries: Array<DirectoryEntry | FileEntry> = useMemo(() => {
52-
if (!apiComponents) {
53-
return [];
54-
}
55-
return transformNodes(
56-
apiComponents.entries,
45+
const mappedData: {
46+
rootEntries: Array<DirectoryEntry | FileEntry>,
47+
pathsRegistry: Set<string>,
48+
} = useMemo(() => {
49+
const pathsRegistry = new Set<string>();
50+
const rootEntries = transformNodes(
51+
apiComponents.entries || [],
5752
'entries',
5853
(node: APIFileEntry | APIDirectoryEntry, parents: APIDirectoryEntry[]) => {
5954
const readMeAPIFile = isDirectory(node) && node.entries.find(e => e.name.toLowerCase() === 'readme.md');
55+
const path = [...parents.map(p => p.name), node.name].join('/');
56+
pathsRegistry.add(path);
6057
return {
6158
name: node.name,
62-
path: [...parents.map(p => p.name), node.name].join('/'),
59+
path,
6360
project: (parents[0] || node)?.name,
6461
package: (parents[0] || node)?.package,
6562
overviewEntry: readMeAPIFile && !isDirectory(readMeAPIFile) && {
@@ -71,9 +68,23 @@ export function EditorViewProvider({ children }: PropsWithChildren) {
7168
} satisfies DirectoryEntry | FileEntry;
7269
},
7370
);
71+
return {
72+
rootEntries,
73+
pathsRegistry,
74+
};
7475
}, [apiComponents]);
7576

76-
const defaultFolderExpansions = rootEntries.filter(rootEntry => !rootEntry.package && rootEntry.path !== newApplication).map<TreeItemIndex>(rootEntry => rootEntry.name);
77+
const reloadRootEntries = useCallback(async () =>
78+
queryClient.fetchQuery<APIDirectoryEntry>({
79+
queryKey: [instanceParams.entityId, 'get_components'],
80+
networkMode: 'online',
81+
}), [queryClient, instanceParams]);
82+
83+
const entryExists = useCallback((path: string) => {
84+
return mappedData.pathsRegistry.has(path);
85+
}, [mappedData.pathsRegistry]);
86+
87+
const defaultFolderExpansions = mappedData.rootEntries.filter(rootEntry => !rootEntry.package && rootEntry.path !== newApplication).map<TreeItemIndex>(rootEntry => rootEntry.name);
7788
let defaultFocusedItem = defaultFolderExpansions[0];
7889
let defaultSelectedItem = defaultFolderExpansions.slice(0, 1);
7990
if (!defaultFocusedItem) {
@@ -181,8 +192,9 @@ export function EditorViewProvider({ children }: PropsWithChildren) {
181192
const value = useMemo<EditorViewContextValue>(() => {
182193
return {
183194

184-
rootEntries,
195+
rootEntries: mappedData.rootEntries,
185196
reloadRootEntries,
197+
entryExists,
186198

187199
focusedItem,
188200
setFocusedItem,
@@ -203,8 +215,9 @@ export function EditorViewProvider({ children }: PropsWithChildren) {
203215

204216
};
205217
}, [
206-
rootEntries,
218+
mappedData.rootEntries,
207219
reloadRootEntries,
220+
entryExists,
208221

209222
focusedItem,
210223
setFocusedItem,

src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ import { useSetComponentFile } from '@/integrations/api/instance/applications/se
2121
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
2222
import { attemptToRestoreFocus } from '@/lib/attemptToRestoreFocus';
2323
import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
24+
import { capitalizeWords } from '@/lib/string/capitalizeWords';
2425
import { zodResolver } from '@hookform/resolvers/zod';
2526
import { Plus } from 'lucide-react';
2627
import { useCallback } from 'react';
2728
import { useForm } from 'react-hook-form';
29+
import { toast } from 'sonner';
2830
import z from 'zod';
2931

3032
export function AddDirectoryOrFileModal() {
@@ -35,7 +37,14 @@ export function AddDirectoryOrFileModal() {
3537
attemptToRestoreFocus(trigger);
3638
}, [trigger]);
3739

38-
const { openedEntry, reloadRootEntries, setFocusedItem, setSelectedItems, setExpandedItems } = useEditorView();
40+
const {
41+
openedEntry,
42+
entryExists,
43+
reloadRootEntries,
44+
setFocusedItem,
45+
setSelectedItems,
46+
setExpandedItems,
47+
} = useEditorView();
3948
const instanceParams = useInstanceClientIdParams();
4049
const { mutate: addFolderFile, isPending } = useSetComponentFile();
4150
const NewFileFolderSchema = z.object({
@@ -57,7 +66,7 @@ export function AddDirectoryOrFileModal() {
5766
});
5867

5968
const submitForm = useCallback((data: z.infer<typeof NewFileFolderSchema>) => {
60-
if (!openedEntry) {
69+
if (!openedEntry || !type) {
6170
return;
6271
}
6372
const splitPath = openedEntry.path.split('/');
@@ -66,9 +75,16 @@ export function AddDirectoryOrFileModal() {
6675
? splitPath.slice(1)
6776
: splitPath.slice(1, -1)
6877
).join('/');
78+
const file = filePath ? `${filePath}/${data.name}` : data.name;
79+
if (entryExists(openedEntry.project + '/' + file)) {
80+
toast.error(`${capitalizeWords(type)} already exists!`, {
81+
description: file,
82+
});
83+
return;
84+
}
6985
addFolderFile(
7086
{
71-
file: `${filePath}/${data.name}`,
87+
file,
7288
project: openedEntry.project,
7389
payload: type === 'directory' ? undefined : '',
7490
...instanceParams,
@@ -87,7 +103,7 @@ export function AddDirectoryOrFileModal() {
87103
},
88104
},
89105
);
90-
}, [addFolderFile, form, closeModal, instanceParams, openedEntry, reloadRootEntries, setExpandedItems, setFocusedItem, setSelectedItems, type]);
106+
}, [addFolderFile, form, closeModal, instanceParams, openedEntry, entryExists, reloadRootEntries, setExpandedItems, setFocusedItem, setSelectedItems, type]);
91107

92108
const onCancelClick = useCallback(() => {
93109
closeModal();

src/features/instance/applications/modals/RenameFileModal.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FormItem } from '@/components/ui/form/FormItem';
1414
import { FormLabel } from '@/components/ui/form/FormLabel';
1515
import { FormMessage } from '@/components/ui/form/FormMessage';
1616
import { Input } from '@/components/ui/input';
17+
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
1718
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
1819
import { useRenameFiles } from '@/features/instance/applications/hooks/useRenameFiles';
1920
import { attemptToRestoreFocus } from '@/lib/attemptToRestoreFocus';
@@ -23,6 +24,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
2324
import { PencilIcon } from 'lucide-react';
2425
import { useCallback, useEffect, useState } from 'react';
2526
import { useForm } from 'react-hook-form';
27+
import { toast } from 'sonner';
2628
import z from 'zod';
2729

2830
export function RenameFileModal() {
@@ -33,7 +35,7 @@ export function RenameFileModal() {
3335
attemptToRestoreFocus(trigger);
3436
}, [trigger]);
3537

36-
const { openedEntry } = useEditorView();
38+
const { openedEntry, entryExists } = useEditorView();
3739
const RenameFileSchema = z.object({
3840
name: z
3941
.string()
@@ -45,7 +47,6 @@ export function RenameFileModal() {
4547
.trim()
4648
.refine((name) => name !== openedEntry?.name, {
4749
error: 'Please enter a new name.',
48-
path: ['name'],
4950
}),
5051
});
5152
const [isPending, setIsPending] = useState(false);
@@ -65,18 +66,20 @@ export function RenameFileModal() {
6566
if (!openedEntry) {
6667
return;
6768
}
69+
const to = renameFileInPath(openedEntry.path, data.name);
70+
if (entryExists(to)) {
71+
toast.error(`${isDirectory(openedEntry) ? 'Directory' : 'File'} already exists!`, {
72+
description: to,
73+
});
74+
return;
75+
}
6876

6977
setIsPending(true);
70-
await renameFiles([
71-
{
72-
from: openedEntry.path,
73-
to: renameFileInPath(openedEntry.path, data.name),
74-
},
75-
]);
78+
await renameFiles([{ from: openedEntry.path, to }]);
7679
closeModal();
7780
form.reset();
7881
setIsPending(false);
79-
}, [form, closeModal, openedEntry, renameFiles, setIsPending]);
82+
}, [closeModal, entryExists, form, openedEntry, renameFiles, setIsPending]);
8083

8184
const onCancelClick = useCallback(() => {
8285
closeModal();

src/features/instance/applications/shortcuts/deleteShortcut.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { currySetWatchedValue, setWatchedValue } from '@/lib/events/watcher';
22
import { Contract } from './contract';
33

44
export const deleteShortcut = {
5-
handleGlobal(key) {
6-
if (key === 'Backspace') {
5+
handleGlobal(key, modifiers) {
6+
if ((modifiers.cmd || modifiers.ctrl) && key === 'Delete') {
77
setWatchedValue('ShowDeleteDirectoryOrFileModal', true);
88
return true;
99
}

src/features/instance/applications/shortcuts/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,5 @@ function keyDownHandler(e: KeyboardEvent) {
5555
return;
5656
}
5757
}
58-
console.log('unhandled key press', e.key, modifiers);
58+
// console.log('unhandled key press', e.key, modifiers);
5959
}

0 commit comments

Comments
 (0)