Project tree view and creation (#203)

* Implement creating and importing new projects

* Add downloading a zip of a project

* Project validation (WIP)

* Add project side panel, remove project pages

* Project file saving

* Add file tree actions to rename and delete

* Fix file creation auto focus

* Add button to save file from menu

* Add project creation

* Fix specificity on version switcher button

* Update default version to 1.19

* List project files by type, remember project and delete project
This commit is contained in:
Misode
2022-06-14 16:48:55 +02:00
committed by GitHub
parent 4942729e7c
commit 90eac0f9b8
39 changed files with 1132 additions and 267 deletions

View File

@@ -1,14 +1,29 @@
import { useMemo, useState } from 'preact/hooks'
import { Octicon } from '.'
import { useFocus } from '../hooks'
const SEPARATOR = '/'
export interface EntryAction {
icon: keyof typeof Octicon,
label: string,
onAction: (entry: string) => unknown,
}
export interface EntryError {
path: string,
message: string,
}
interface Props {
entries: string[],
onSelect: (entry: string) => unknown,
selected?: string,
actions?: EntryAction[],
errors?: EntryError[],
indent?: number,
}
export function TreeView({ entries, onSelect, indent }: Props) {
export function TreeView({ entries, onSelect, selected, actions, errors, indent }: Props) {
const roots = useMemo(() => {
const groups: Record<string, string[]> = {}
for (const entry of entries) {
@@ -18,12 +33,16 @@ export function TreeView({ entries, onSelect, indent }: Props) {
;(groups[root] ??= []).push(entry.slice(i + 1))
}
}
return Object.entries(groups)
}, entries)
return Object.entries(groups).map(([r, entries]) => {
const rootActions = actions?.map(a => ({ ...a, onAction: (e: string) => a.onAction(r + SEPARATOR + e) }))
const rootErrors = errors?.flatMap(e => e.path.startsWith(r + SEPARATOR) ? [{ ...e, path: e.path.slice(r.length + SEPARATOR.length) }] : [])
return [r, entries, rootActions, rootErrors] as [string, string[], EntryAction[], EntryError[]]
}).sort()
}, [entries, actions, errors])
const leaves = useMemo(() => {
return entries.filter(e => !e.includes(SEPARATOR))
}, entries)
}, [entries])
const [hidden, setHidden] = useState(new Set<string>())
const toggle = (root: string) => {
@@ -36,23 +55,42 @@ export function TreeView({ entries, onSelect, indent }: Props) {
}
return <div class="tree-view" style={`--indent: ${indent ?? 0};`}>
{roots.map(([r, entries]) => <div>
<TreeViewEntry icon={hidden.has(r) ? 'chevron_right' : 'chevron_down'} key={r} label={r} onClick={() => toggle(r)}/>
{roots.map(([r, entries, actions, errors]) => <div>
<TreeViewEntry icon={hidden.has(r) ? 'chevron_right' : 'chevron_down'} key={r} label={r} onClick={() => toggle(r)} error={(errors?.length ?? 0) > 0} />
{!hidden.has(r) &&
<TreeView entries={entries} onSelect={e => onSelect(`${r}/${e}`)} indent={(indent ?? 0) + 1} />}
<TreeView entries={entries} onSelect={e => onSelect(`${r}${SEPARATOR}${e}`)}
selected={selected?.startsWith(r + SEPARATOR) ? selected.substring(r.length + 1) : undefined}
actions={actions} errors={errors} indent={(indent ?? 0) + 1} />}
</div>)}
{leaves.map(e => <TreeViewEntry icon="file" key={e} label={e} onClick={() => onSelect(e)} />)}
{leaves.map(e => <TreeViewEntry icon="file" key={e} label={e} active={e === selected} onClick={() => onSelect(e)} actions={actions?.map(a => ({ ...a, onAction: () => a.onAction(e) }))} error={errors?.find(er => er.path === e)?.message} />)}
</div>
}
interface TreeViewEntryProps {
icon: keyof typeof Octicon,
label: string,
active?: boolean,
onClick?: () => unknown,
actions?: EntryAction[],
error?: string | boolean,
}
function TreeViewEntry({ icon, label, onClick }: TreeViewEntryProps) {
return <div class="entry" onClick={onClick} >
function TreeViewEntry({ icon, label, active, onClick, actions, error }: TreeViewEntryProps) {
const [focused, setFocus] = useFocus()
const onContextMenu = (evt: MouseEvent) => {
evt.preventDefault()
if (actions?.length) {
setFocus()
}
}
return <div class={`entry${error ? ' has-error' : ''}${active ? ' active' : ''}${focused ? ' focused' : ''}`} onClick={onClick} onContextMenu={onContextMenu} >
{Octicon[icon]}
{label}
<span>{label.replaceAll('\u2215', '/')}</span>
{typeof error === 'string' && <div class="status-icon danger tooltipped tip-se" aria-label={error}>
{Octicon.issue_opened}
</div>}
{focused && <div class="entry-menu">
{actions?.map(a => <div class="action" onClick={e => { a.onAction(''); e.stopPropagation(); setFocus(false) }}>{Octicon[a.icon]}{a.label}</div>)}
</div>}
</div>
}