Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -5,6 +5,7 @@ import { filesystemService } from '~/services/filesystem-service'
import type { FilesystemEntry } from '~/types/filesystem.types'
import { ApiError } from '~/utils/api'
import { useDirectoryWatcher } from '~/hooks/use-file-watcher'
import { normalizePath } from '~/utils/path-utils'
import Button from '../inputs/button'
import CloseButton from '../inputs/close-button'

Expand Down Expand Up @@ -38,9 +39,9 @@ export default function DirectoryPicker({
setIsCreatingFolder(false)
try {
const result = await filesystemService.browse(path)
setEntries(result.entries)
setCurrentPath(result.resolvedPath)
setParentPath(result.parentPath)
setEntries(result.entries.map((entry) => ({ ...entry, path: normalizePath(entry.path) })))
setCurrentPath(normalizePath(result.resolvedPath))
setParentPath(normalizePath(result.parentPath))
} catch (error) {
if (error instanceof ApiError && error.httpCode === 403) {
setError('Access denied')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const CONFIGURATION_NAME_PATTERNS: Record<string, RegExp> = {

export const FOLDER_OR_ADAPTER_NAME_PATTERNS: Record<string, RegExp> = BASE_NAME_PATTERNS

export const PROJECT_NAME_PATTERNS: Record<string, RegExp> = {
...BASE_NAME_PATTERNS,
'Cannot have a file extension': /^(?!.*\.[^.\\/]+$).*$/,
}

interface NameInputDialogProps {
title: string
initialValue?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,30 @@ export default function AddConfigurationModal({
setLoading(false)
return
}

if (/[/\\]/.test(configname) || configname.includes('..')) {
setError(String.raw`Filename cannot contain "/", "\" or ".."`)
setLoading(false)
return
}
// Ensure .xml suffix
if (!configname.toLowerCase().endsWith('.xml')) {
configname = `${configname}.xml`
}

await createConfigurationFile(currentConfiguration.name, `${rootLocationName}/${configname}`)
/*
* The backend resolves the configuration name relative to the project, so pass a
* path relative to the project root (matching the studio's "New Configuration File").
* Trailing separators are stripped as a defence-in-depth guard against broken paths.
*/
const projectRoot = currentConfiguration.rootPath.replace(/[/\\]$/, '')
const selectedFolder = rootLocationName.replace(/[/\\]$/, '')
const relativePath =
!selectedFolder || selectedFolder === projectRoot
? configname
: `${selectedFolder.slice(projectRoot.length + 1).replaceAll('\\', '/')}/${configname}`

await createConfigurationFile(currentConfiguration.name, relativePath)
const updatedProject = await fetchProject(currentConfiguration.name)
setProject(updatedProject)
onSuccess?.()
Expand Down Expand Up @@ -83,10 +101,13 @@ export default function AddConfigurationModal({
setIsOpenPickerOpen(false)
}

const trimmedFilename = filename.trim()
const filenameHasInvalidChars = /[/\\]/.test(trimmedFilename) || trimmedFilename.includes('..')
const isFilenameValid = trimmedFilename.length > 0 && !filenameHasInvalidChars

const displayFilename = (() => {
const trimmed = filename.trim()
if (!trimmed) return ''
return trimmed.toLowerCase().endsWith('.xml') ? trimmed : `${trimmed}.xml`
if (!trimmedFilename) return ''
return trimmedFilename.toLowerCase().endsWith('.xml') ? trimmedFilename : `${trimmedFilename}.xml`
})()

return (
Expand Down Expand Up @@ -120,19 +141,25 @@ export default function AddConfigurationModal({
<label className="text-sm font-medium" htmlFor="configuration-filename-input">
Filename
</label>
<div className="ml-2 flex w-full items-center">
<Input
id="configuration-filename-input"
value={filename}
onChange={(event) => setFilename(event.target.value)}
placeholder="Choose a filename"
aria-label="configuration filename"
/>
<div className="ml-2 flex w-full flex-col">
<div className="flex w-full items-center gap-1">
<Input
id="configuration-filename-input"
value={filename}
onChange={(event) => setFilename(event.target.value)}
placeholder="Choose a filename"
aria-label="configuration filename"
/>
<span className="text-foreground-muted text-sm">.xml</span>
</div>
{filenameHasInvalidChars && (
<p className="mt-1 text-xs text-red-500">{String.raw`Filename cannot contain "/", "\" or ".."`}</p>
)}
</div>
</div>

<div className="flex gap-2">
<Button onClick={handleAdd} disabled={loading} className="disabled:opacity-50">
<Button onClick={handleAdd} disabled={loading || !isFilenameValid} className="disabled:opacity-50">
{loading ? 'Adding...' : `Add ${displayFilename || 'configuration file'} to ${currentConfiguration.name}`}
</Button>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,14 @@ import type { FileTreeNode } from '~/types/filesystem.types'
import { fetchProjectTree } from '~/services/file-tree-service'
import Button from '~/components/inputs/button'
import Search from '~/components/search/search'
import { normalizePath, toRelativePath } from '~/utils/path-utils'
import { toRelativePath } from '~/utils/path-utils'

interface ConfigurationFile {
path: string
relativePath: string
adapterNames: string[]
}

function findConfigurationsDir(node: FileTreeNode | undefined | null): FileTreeNode | null {
if (!node || !node.path) return null

const normalizedPath = normalizePath(node.path)

if (node.type === 'DIRECTORY' && normalizedPath.endsWith(`/src/main/configurations/${node.name}`)) {
return node
}

if (!node.children) return null

for (const child of node.children) {
const found = findConfigurationsDir(child)
if (found) return found
}

return null
}

function collectXmlFiles(node: FileTreeNode | undefined | null): FileTreeNode[] {
let result: FileTreeNode[] = []
if (!node) return result
Expand All @@ -61,7 +42,6 @@ export default function ConfigurationOverview() {
const [showModal, setShowModal] = useState(false)
const [tree, setTree] = useState<FileTreeNode | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [configurationsDir, setConfigurationsDir] = useState<FileTreeNode | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery)

Expand Down Expand Up @@ -91,13 +71,6 @@ export default function ConfigurationOverview() {
return () => controller.abort()
}, [loadTree])

useEffect(() => {
if (tree) {
const configDir = findConfigurationsDir(tree)
setConfigurationsDir(configDir)
}
}, [tree])

const handleConfigAdded = useCallback(() => {
setShowModal(false)
loadTree()
Expand All @@ -112,12 +85,15 @@ export default function ConfigurationOverview() {
const configFiles = useMemo(() => {
if (!tree || !currentConfigurationProject) return []

const configurationDirectory = findConfigurationsDir(tree)
if (!configurationDirectory) return []

const xmlFiles = collectXmlFiles(configurationDirectory)
/*
* The project root already is the configuration directory (it is the opened
* src/main/configurations/<configuration> folder, or a freshly created project
* whose Configuration.xml lives at the top level), so collect the configuration
* files straight from the root rather than searching for a nested directory.
*/
const xmlFiles = collectXmlFiles(tree)
return xmlFiles.map((file) => {
const relativePath = toRelativePath(file.path, `${configurationDirectory.path}/`) ?? file.name
const relativePath = toRelativePath(file.path, `${tree.path}/`) ?? file.name
return { ...file, relativePath, path: file.path }
})
}, [tree, currentConfigurationProject])
Expand Down Expand Up @@ -208,7 +184,7 @@ export default function ConfigurationOverview() {
onClose={() => setShowModal(false)}
onSuccess={handleConfigAdded}
currentConfiguration={currentConfigurationProject}
configurationsDirPath={configurationsDir?.path ?? ''}
configurationsDirPath={tree?.path ?? ''}
/>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '~/components/inputs/button'
import CloseButton from '~/components/inputs/close-button'
import Input from '~/components/inputs/input'
import { filesystemService } from '~/services/filesystem-service'
import { joinPath, normalizePath } from '~/utils/path-utils'

interface CloneProjectModalProperties {
isOpen: boolean
Expand Down Expand Up @@ -33,7 +34,7 @@ export default function CloneConfigurationModal({

filesystemService
.resolveNearestAccessiblePath(initialPath ?? '')
.then(setLocation)
.then((resolved) => setLocation(normalizePath(resolved)))
.catch(() => setLocation(''))
}, [isOpen, isLocal, initialPath])

Expand All @@ -44,21 +45,14 @@ export default function CloneConfigurationModal({
.pop()
?.replace(/\.git$/, '')

const targetName = repoName || 'cloned-project'
const targetPath = location ? joinPath(location, targetName) : targetName

const handleClone = () => {
if (!repoUrl.trim()) return
if (isLocal && !location) return

let finalPath: string

if (isLocal) {
const separator = location.includes('/') ? '/' : '\\'
finalPath = `${location}${separator}${repoName}`
} else {
const name = repoName || 'cloned-project'
finalPath = location ? `${location}/${name}` : name
}

onClone(repoUrl.trim(), finalPath, token || undefined)
onClone(repoUrl.trim(), targetPath, token || undefined)
handleClose()
}

Expand Down Expand Up @@ -118,14 +112,7 @@ export default function CloneConfigurationModal({
</div>
)}

{repoName && (
<p className="text-foreground-muted mb-4 text-xs">
Will clone to:{' '}
{isLocal
? `${location}${location.includes('/') ? '/' : '\\'}${repoName}`
: `${location ? `${location}/` : ''}${repoName}`}
</p>
)}
{repoName && <p className="text-foreground-muted mb-4 text-xs">Will clone to: {targetPath}</p>}

<div className="flex gap-2">
<Button
Expand All @@ -144,7 +131,7 @@ export default function CloneConfigurationModal({
<DirectoryPicker
isOpen={showPicker}
onSelect={(path) => {
setLocation(path)
setLocation(normalizePath(path))
setShowPicker(false)
}}
onCancel={() => setShowPicker(false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import DirectoryPicker from '~/components/directory-picker/directory-picker'
import Button from '~/components/inputs/button'
import CloseButton from '~/components/inputs/close-button'
import Input from '~/components/inputs/input'
import ValidatedInput from '~/components/inputs/validatedInput'
import { PROJECT_NAME_PATTERNS } from '~/components/file-structure/name-input-dialog'
import { filesystemService } from '~/services/filesystem-service'
import { joinPath, normalizePath, stripTrailingSeparators } from '~/utils/path-utils'

interface NewProjectModalProperties {
isOpen: boolean
Expand All @@ -13,8 +16,6 @@ interface NewProjectModalProperties {
initialPath: string
}

const CONFIG_DIR = 'src/main/configurations'

export default function NewConfigurationModal({
isOpen,
isLocal,
Expand All @@ -23,6 +24,7 @@ export default function NewConfigurationModal({
initialPath,
}: Readonly<NewProjectModalProperties>) {
const [name, setName] = useState('')
const [isNameValid, setIsNameValid] = useState(false)
const [location, setLocation] = useState('')
const [showPicker, setShowPicker] = useState(false)

Expand All @@ -34,16 +36,16 @@ export default function NewConfigurationModal({

filesystemService
.resolveNearestAccessiblePath(initialPath)
.then(setLocation)
.then((resolved) => setLocation(normalizePath(resolved)))
.catch(() => setLocation(''))
}, [isOpen, isLocal, initialPath])

if (!isOpen) return null

const handleCreate = () => {
if (!name.trim() || (isLocal && !location)) return
if (!isNameValid || !name.trim() || (isLocal && !location)) return
const trimmedName = name.trim()
onCreate(trimmedName, location ?? '')
onCreate(trimmedName, stripTrailingSeparators(location ?? ''))
handleClose()
}

Expand Down Expand Up @@ -81,8 +83,10 @@ export default function NewConfigurationModal({

<div className="mb-4">
<label className="mb-1 block text-sm font-medium">Configuration Name</label>
<Input
<ValidatedInput
value={name}
patterns={PROJECT_NAME_PATTERNS}
onValidChange={setIsNameValid}
onChange={(event) => setName(event.target.value)}
placeholder="Enter configuration name"
/>
Expand All @@ -98,7 +102,7 @@ export default function NewConfigurationModal({
<div className="flex gap-2">
<Button
onClick={handleCreate}
disabled={!name.trim() || (isLocal && !location)}
disabled={!isNameValid || !name.trim() || (isLocal && !location)}
className="disabled:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Create Configuration
Expand All @@ -112,7 +116,7 @@ export default function NewConfigurationModal({
<DirectoryPicker
isOpen={showPicker}
onSelect={(path) => {
setLocation(path)
setLocation(normalizePath(path))
setShowPicker(false)
}}
onCancel={() => setShowPicker(false)}
Expand All @@ -123,9 +127,6 @@ export default function NewConfigurationModal({
}

function getConfigurationPath(location: string, name: string, isLocal: boolean) {
let configPath = isLocal ? location.replace('\\', '/') : location
if (!configPath.endsWith(CONFIG_DIR)) {
configPath = `${configPath}/${CONFIG_DIR}`
}
return `${configPath}/${name.trim()}`
const base = isLocal ? normalizePath(location) : location
return joinPath(base, name.trim())
}
Loading
Loading