Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import FolderIcon from '/icons/solar/Folder.svg?react'
import NameInputDialog from '~/components/file-structure/name-input-dialog'
import { filesystemService } from '~/services/filesystem-service'
import type { FilesystemEntry } from '~/types/filesystem.types'
import { ApiError } from '~/utils/api'
Expand All @@ -26,11 +27,13 @@ export default function DirectoryPicker({
const [selectedEntry, setSelectedEntry] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isCreatingFolder, setIsCreatingFolder] = useState(false)

const loadEntries = useCallback(async (path: string) => {
setLoading(true)
setError(null)
setSelectedEntry(null)
setIsCreatingFolder(false)
try {
const result = await filesystemService.browse(path)
setEntries(result.entries)
Expand All @@ -47,10 +50,6 @@ export default function DirectoryPicker({
}
}, [])

const handleNavigateUp = () => {
loadEntries(parentPath)
}

useEffect(() => {
if (isOpen) {
setSelectedEntry(null)
Expand All @@ -60,8 +59,6 @@ export default function DirectoryPicker({

if (!isOpen) return null

const canGoUp = parentPath !== ''

const handleClick = (entry: FilesystemEntry) => {
setSelectedEntry(entry.path)
}
Expand All @@ -70,6 +67,21 @@ export default function DirectoryPicker({
loadEntries(entry.path)
}

const handleCreateFolder = async (folderName: string) => {
const basePath = selectedEntry ?? currentPath
const separator = basePath.includes('\\') ? '\\' : '/'
const newPath = `${basePath}${separator}${folderName}`
setIsCreatingFolder(false)
try {
await filesystemService.createDirectory(newPath)
await loadEntries(basePath)
setSelectedEntry(newPath)
} catch {
setError('Failed to create folder')
}
}

const canGoUp = parentPath !== ''
const activePath = selectedEntry ?? currentPath

return (
Expand All @@ -87,21 +99,32 @@ export default function DirectoryPicker({

<div className="border-border flex items-center gap-2 border-b px-4 py-2">
<Button
onClick={handleNavigateUp}
onClick={() => loadEntries(parentPath)}
disabled={!canGoUp}
className="disabled:text-foreground-muted text-xs disabled:opacity-30"
>
..
</Button>
<span className="text-foreground-muted truncate text-xs">{currentPath || rootLabel}</span>
<span className="text-foreground-muted flex-1 truncate text-xs">{currentPath || rootLabel}</span>
{currentPath && !isCreatingFolder && (
<Button
onClick={() => setIsCreatingFolder(true)}
className="text-foreground-muted hover:text-foreground shrink-0 text-xs"
title="Create a new folder here"
>
New Folder
</Button>
)}
</div>

<div className="flex-1 overflow-y-auto p-2">
{loading && <p className="text-foreground-muted p-4 text-center text-xs">Loading...</p>}
<div className="flex-1 overflow-y-auto py-1">
{loading && <p className="text-foreground-muted p-4 text-center text-xs">Loading</p>}
{error && <p className="p-4 text-center text-xs text-red-500">{error}</p>}
{!loading && !error && entries.length === 0 && (

{!loading && !error && entries.length === 0 && !isCreatingFolder && (
<p className="text-foreground-muted p-4 text-center text-xs italic">No subdirectories</p>
)}

{!loading &&
!error &&
entries.map((entry) => (
Expand All @@ -113,15 +136,24 @@ export default function DirectoryPicker({
selectedEntry === entry.path ? 'bg-backdrop font-medium' : 'hover:bg-backdrop/50'
}`}
>
<span className="relative text-xs">
<FolderIcon className="fill-foreground w-4 flex-shrink-0" />
<span className="relative flex-shrink-0 text-xs">
<FolderIcon className="fill-foreground w-4" />
{entry.projectRoot && (
<span className="absolute bottom-0.5 h-1.5 w-1.5 rounded-full bg-black" style={{ left: '65%' }} />
)}
</span>
<span className="truncate">{entry.name}</span>
</button>
))}

{isCreatingFolder && (
<NameInputDialog
title="New Folder"
submitLabel="Create"
onSubmit={(name) => void handleCreateFolder(name)}
onCancel={() => setIsCreatingFolder(false)}
/>
)}
</div>

<div className="border-border flex items-center justify-between border-t px-4 py-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function FileTreeDialogs({
<NameInputDialog
title={nameDialog.title}
initialValue={nameDialog.initialValue}
submitLabel={nameDialog.submitLabel}
onSubmit={nameDialog.onSubmit}
onCancel={onCloseNameDialog}
patterns={nameDialog.patterns}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const FOLDER_OR_ADAPTER_NAME_PATTERNS: Record<string, RegExp> = BASE_NAME
interface NameInputDialogProps {
title: string
initialValue?: string
submitLabel?: string
onSubmit: (name: string) => void
onCancel: () => void
patterns?: Record<string, RegExp>
Expand All @@ -33,6 +34,7 @@ interface NameInputDialogProps {
export default function NameInputDialog({
title,
initialValue = '',
submitLabel = 'OK',
onSubmit,
onCancel,
patterns = FOLDER_OR_ADAPTER_NAME_PATTERNS,
Expand Down Expand Up @@ -63,7 +65,7 @@ export default function NameInputDialog({
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/30"
onClick={handleOverlayClick}
>
<div className="bg-background border-border w-80 rounded-md border p-4 shadow-lg">
Expand All @@ -82,7 +84,7 @@ export default function NameInputDialog({
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid} className="px-3 py-1 text-sm">
OK
{submitLabel}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default function StudioFileTreeDialogs({
<NameInputDialog
title={nameDialog.title}
initialValue={nameDialog.initialValue}
submitLabel={nameDialog.submitLabel}
onSubmit={nameDialog.onSubmit}
onCancel={onCloseNameDialog}
patterns={nameDialog.patterns}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ContextMenuState {
export interface NameDialogState {
title: string
initialValue?: string
submitLabel?: string
onSubmit: (name: string) => void
patterns?: Record<string, RegExp>
}
Expand Down Expand Up @@ -118,6 +119,7 @@ export function useFileTreeContextMenu({

setNameDialog({
title: 'New File',
submitLabel: 'Create',
onSubmit: async (name: string) => {
if (!ensureHasCorrectExtension(name)) {
showErrorToast(`Filename must have one of the following extensions: ${ALLOWED_EXTENSIONS.join(', ')}`)
Expand Down Expand Up @@ -148,6 +150,7 @@ export function useFileTreeContextMenu({

setNameDialog({
title: 'New Folder',
submitLabel: 'Create',
onSubmit: async (name: string) => {
try {
await createFolderInProject(projectName, `${parentPath}/${name}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface StudioContextMenuState {
export interface NameDialogState {
title: string
initialValue?: string
submitLabel?: string
onSubmit: (name: string) => void
patterns?: Record<string, RegExp>
}
Expand Down Expand Up @@ -171,6 +172,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon

setNameDialog({
title: 'New Configuration File',
submitLabel: 'Create',
onSubmit: async (name: string) => {
const fileName = ensureXmlExtension(name)
try {
Expand Down Expand Up @@ -201,6 +203,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon

setNameDialog({
title: 'New Adapter',
submitLabel: 'Create',
onSubmit: async (name: string) => {
try {
await createAdapter(projectName, name, menu.path)
Expand All @@ -224,6 +227,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon

setNameDialog({
title: 'New Folder',
submitLabel: 'Create',
onSubmit: async (name: string) => {
try {
await createFolderInProject(projectName, `${menu.folderPath}/${name}`)
Expand Down
4 changes: 4 additions & 0 deletions src/main/frontend/app/services/filesystem-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ export const filesystemService = {
const result = await this.browse(path)
return result.resolvedPath
},

async createDirectory(path: string): Promise<void> {
return apiFetch(`/filesystem/mkdir?path=${encodeURIComponent(path)}`, { method: 'POST' })
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -28,4 +29,16 @@ public ResponseEntity<BrowseResult> browse(@RequestParam(required = false, defau
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}

@PostMapping("/mkdir")
public ResponseEntity<Void> mkdir(@RequestParam String path) throws IOException {
try {
fileSystemStorage.createProjectDirectory(path);
return ResponseEntity.ok().build();
} catch (AccessDeniedException _) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} catch (SecurityException _) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
}
Loading