import React, { useEffect, useState, useMemo, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
import UpdateNotifier from './components/UpdateNotifier.jsx'
import Icon from './components/Icon.jsx'
import {
checkForAppUpdate,
installAppUpdate,
skipUpdateVersion,
getLastUpdateCheck,
formatLastCheck,
isTauri,
} from './utils/updater.js'
// Transitive Dependency-Aufloesung: gibt Set aller Module zurueck, die
// aktiviert sein muessen, wenn `selected` aktiviert sind.
function resolveDeps(selected, allModules) {
const byId = Object.fromEntries(allModules.map(m => [m.id, m]))
const out = new Set(selected)
let changed = true
while (changed) {
changed = false
for (const id of [...out]) {
const m = byId[id]
if (!m) continue
for (const dep of (m.dependsOn || [])) {
if (!out.has(dep)) { out.add(dep); changed = true }
}
}
}
return out
}
function formatRelative(iso) {
if (!iso) return ''
try {
const d = new Date(iso)
const now = new Date()
const diff = (now - d) / 1000
if (diff < 60) return 'vor wenigen Sek.'
if (diff < 3600) return `vor ${Math.floor(diff/60)} Min.`
if (diff < 86400) return `vor ${Math.floor(diff/3600)} h`
if (diff < 86400 * 7) return `vor ${Math.floor(diff/86400)} Tagen`
return d.toLocaleDateString('de-CH')
} catch { return '' }
}
function formatFileSize(bytes) {
if (!bytes && bytes !== 0) return ''
const mb = bytes / (1024 * 1024)
if (mb >= 1) return `${mb.toFixed(1)} MB`
return `${(bytes / 1024).toFixed(0)} KB`
}
// Sortierung: gepinnte zuerst (intern nach lastOpened), dann der Rest nach
// lastOpened. Stabil — ein Toggle der Pin-Eigenschaft veraendert die Reihenfolge
// vorhersagbar.
function sortProjects(list) {
return [...list].sort((a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
return (b.lastOpened || '').localeCompare(a.lastOpened || '')
})
}
function newTagId() {
return 'tag_' + Math.random().toString(36).slice(2, 9)
}
// Schwarz/Weiss-Kontrast fuer Tag-Chip-Text
function isDarkColor(hex) {
if (!hex) return true
const h = hex.replace('#', '')
const n = h.length === 3
? [0,1,2].map(i => parseInt(h[i] + h[i], 16))
: [0,2,4].map(i => parseInt(h.slice(i, i+2), 16))
// Perceived brightness
return (n[0] * 0.299 + n[1] * 0.587 + n[2] * 0.114) < 140
}
export default function App() {
const [recent, setRecent] = useState([])
const [modules, setModules] = useState([])
const [dialog, setDialog] = useState(null) // null | { mode: 'new'|'edit', project? }
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsTab, setSettingsTab] = useState('rhino')
const [busy, setBusy] = useState(false)
const [query, setQuery] = useState('')
// Window-Layouts + aktuell gewaehltes Default — fuer Quick-Switcher in Topbar
const [layouts, setLayouts] = useState([])
const [activeLayout, setActiveLayout] = useState('')
// Rhino-Status: alle 5s pollen
const [rhinoRunning, setRhinoRunning] = useState(false)
// Tags aus den globalen Settings — Settings-Tab editiert sie, Dialog + Card
// konsumieren sie. Single Source of Truth ist die Tauri-settings.json.
const [tags, setTags] = useState([])
// File-Meta pro Pfad (Map). Wird beim Mount + nach OpenProject neu geladen.
const [fileMeta, setFileMeta] = useState({})
// Aktiv gefilterte Tag-IDs (Topbar-Filter). Leeres Set = alle anzeigen.
const [tagFilter, setTagFilter] = useState(new Set())
// Snapshot-Toast (Pfad oder null)
const [snapshotToast, setSnapshotToast] = useState(null)
// View-Tabs: 'recent' = kuerzlich geoeffnete, 'all' = alle mit Sort/Group
const [view, setView] = useState('recent')
const [sortBy, setSortBy] = useState('lastOpened') // lastOpened | name | createdAt
const [groupByTag, setGroupByTag] = useState(false)
// Pro-Projekt-Pfad -> bool (.rhl-Lock vorhanden?)
const [openMap, setOpenMap] = useState({})
// Pro-Pfad -> dataUrl (PNG-Thumbnail) | null
const [thumbMap, setThumbMap] = useState({})
useEffect(() => {
invoke('list_recent').then(r => setRecent(sortProjects(r || []))).catch(console.error)
invoke('read_modules_manifest').then(setModules).catch(console.error)
invoke('list_window_layouts').then(setLayouts).catch(() => {})
invoke('read_dossier_settings').then(ds => setActiveLayout(ds?.windowLayout || '')).catch(() => {})
invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {})
}, [])
// File-Meta laden sobald recent sich aendert
useEffect(() => {
let alive = true
Promise.all((recent || []).map(p =>
invoke('get_file_meta', { path: p.path })
.then(m => [p.path, m]).catch(() => [p.path, null])
)).then(pairs => {
if (!alive) return
const map = {}
for (const [k, v] of pairs) map[k] = v
setFileMeta(map)
})
return () => { alive = false }
}, [recent])
// Thumbnails laden — Initial + alle 30s neu (Cache-Refresh nach Saves)
useEffect(() => {
let alive = true
const load = async () => {
if (recent.length === 0) return
const pairs = await Promise.all(recent.map(p =>
invoke('read_thumbnail', { path: p.path })
.then(r => [p.path, r?.dataUrl || null])
.catch(() => [p.path, null])
))
if (!alive) return
const next = {}
for (const [k, v] of pairs) next[k] = v
setThumbMap(next)
}
load()
const t = setInterval(load, 30000)
return () => { alive = false; clearInterval(t) }
}, [recent])
// Rhino-Status-Poll + Open-Status pro File (.rhl-Check fuer primary + extras)
useEffect(() => {
let alive = true
const check = async () => {
try {
const running = await invoke('is_rhino_running')
if (!alive) return
setRhinoRunning(!!running)
if (running && recent.length > 0) {
// Alle zu checkenden Pfade sammeln (primary + extras) ohne Duplikate
const paths = new Set()
for (const p of recent) {
paths.add(p.path)
for (const f of (p.extraFiles || [])) paths.add(f)
}
const results = await Promise.all([...paths].map(pth =>
invoke('is_project_open', { path: pth })
.then(b => [pth, !!b]).catch(() => [pth, false])
))
if (!alive) return
const map = {}
for (const [k, v] of results) map[k] = v
setOpenMap(map)
} else {
if (alive) setOpenMap({})
}
} catch {}
}
check()
const t = setInterval(check, 5000)
return () => { alive = false; clearInterval(t) }
}, [recent])
// Tray neu bauen sobald recent sich aendert (Reihenfolge, Inhalt)
const syncTray = useCallback(() => {
invoke('refresh_tray_menu').catch(() => {})
}, [])
// Gefiltert nach Query + aktiven Tag-Filtern
const filteredRecent = useMemo(() => {
const q = query.trim().toLowerCase()
return recent.filter(p => {
if (q) {
const inText =
(p.name || '').toLowerCase().includes(q) ||
(p.path || '').toLowerCase().includes(q)
if (!inText) return false
}
if (tagFilter.size > 0) {
const pTags = new Set(p.tagIds || [])
for (const t of tagFilter) if (!pTags.has(t)) return false
}
return true
})
}, [recent, query, tagFilter])
// 'Recent'-View: nur die letzten 8, ohne Tag-Filter
const recentSlice = useMemo(() => {
return [...recent]
.sort((a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
return (b.lastOpened || '').localeCompare(a.lastOpened || '')
})
.slice(0, 8)
}, [recent])
// 'Alle'-View: Sortierung nach gewaehltem Kriterium (Pin immer oben)
const allSorted = useMemo(() => {
const cmp = (a, b) => {
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
switch (sortBy) {
case 'name': return (a.name || '').localeCompare(b.name || '')
case 'createdAt': return (b.createdAt || b.lastOpened || '').localeCompare(a.createdAt || a.lastOpened || '')
case 'lastOpened':
default: return (b.lastOpened || '').localeCompare(a.lastOpened || '')
}
}
return [...filteredRecent].sort(cmp)
}, [filteredRecent, sortBy])
// Wenn group-by-tag aktiv: Map tagId -> Projekte. Untagged kommt zuletzt.
const allGrouped = useMemo(() => {
if (!groupByTag) return null
const groups = new Map()
for (const t of tags) groups.set(t.id, [])
groups.set('__untagged__', [])
for (const p of allSorted) {
const ids = (p.tagIds || []).filter(id => groups.has(id))
if (ids.length === 0) {
groups.get('__untagged__').push(p)
} else {
for (const id of ids) groups.get(id).push(p)
}
}
// Leere Gruppen wegwerfen
return Array.from(groups.entries()).filter(([_, ps]) => ps.length > 0)
}, [allSorted, groupByTag, tags])
const togglePin = async (proj) => {
const next = sortProjects(recent.map(p =>
p.path === proj.path ? { ...p, pinned: !p.pinned } : p
))
setRecent(next)
await invoke('save_recent', { projects: next }).catch(() => {})
syncTray()
}
const snapshotProject = async (proj) => {
try {
const target = await invoke('create_snapshot', { path: proj.path })
setSnapshotToast(target)
setTimeout(() => setSnapshotToast(null), 2400)
} catch (ex) {
alert(`Snapshot fehlgeschlagen: ${ex}`)
}
}
const toggleTagFilter = (tagId) => {
setTagFilter(prev => {
const next = new Set(prev)
if (next.has(tagId)) next.delete(tagId); else next.add(tagId)
return next
})
}
const onPickLayout = async (name) => {
setActiveLayout(name)
// Default speichern (fuer Auto-Apply beim naechsten Start)
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: { ...cur, windowLayout: name || null },
}).catch(() => {})
if (name) {
// Bei aktiver Rhino-Session: direkt anwenden via Polling-Flag
await invoke('apply_layout_now', { name }).catch(() => {})
}
}
// Tray-Menue navigiert ueber das `dossier:navigate` Event.
useEffect(() => {
let unlisten = null
listen('dossier:navigate', (e) => {
const view = e.payload
if (view === 'settings') { setSettingsTab('rhino'); setSettingsOpen(true) }
else if (view === 'new') { setDialog({ mode: 'new' }) }
else if (view === 'check-update') {
setSettingsTab('updates')
setSettingsOpen(true)
// Modal-Pfad ueber UpdateNotifier wuerde nur bei verfuegbarem Update
// erscheinen — fuer den manuellen Check zeigen wir den Settings-Tab,
// der direkt den Check-Button hat.
}
}).then(fn => { unlisten = fn })
return () => { if (unlisten) unlisten() }
}, [])
const openProject = async (proj, fileOverride = null) => {
if (busy) return
const target = fileOverride || proj.path
setBusy(true)
try {
// Per-Projekt-Layout NUR beim Oeffnen der Primary-Datei anwenden —
// bei Extra-Files wollen wir vermutlich nicht das Layout switchen.
if (!fileOverride && proj.windowLayout) {
try { await invoke('apply_layout_now', { name: proj.windowLayout }) }
catch (ex) { console.warn('per-project layout:', ex) }
}
await invoke('open_rhino', { path3dm: target })
const next = recent
.map(p => p.path === proj.path ? { ...p, lastOpened: new Date().toISOString() } : p)
.sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || ''))
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
} catch (ex) {
alert(`Rhino-Start fehlgeschlagen: ${ex}`)
} finally {
setBusy(false)
}
}
const revealProject = async (proj) => {
try { await invoke('show_in_finder', { path: proj.path }) }
catch (ex) { alert(`Im Finder zeigen: ${ex}`) }
}
const editProject = (proj) => {
setDialog({ mode: 'edit', project: proj })
}
const saveProject = async ({ name, path, modules: mods, useTemplate, windowLayout,
tagIds, extraFiles }) => {
if (!path) { alert('Bitte eine .3dm-Datei auswaehlen.'); return }
if (!name.trim()) { alert('Bitte einen Projekt-Namen angeben.'); return }
try {
if (useTemplate) {
try { await invoke('copy_template_to', { targetPath: path }) }
catch (ex) { console.warn('Template-Kopie:', ex) }
}
await invoke('write_project_config', { path3dm: path, name: name.trim(), modules: mods })
const existing = recent.find(p => p.path === path)
const others = recent.filter(p => p.path !== path)
const now = new Date().toISOString()
const merged = {
name: name.trim(),
path,
modules: mods,
windowLayout: windowLayout || null,
tagIds: tagIds || [],
extraFiles: extraFiles || [],
pinned: existing?.pinned || false,
createdAt: existing?.createdAt || now,
lastOpened: existing?.lastOpened || now,
}
const next = sortProjects([merged, ...others])
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
setDialog(null)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
const removeProject = async (proj) => {
const next = recent.filter(p => p.path !== proj.path)
await invoke('save_recent', { projects: next })
setRecent(next)
syncTray()
}
return (
DOSSIER.
v0.6.3
Pre-Release
Rhino {rhinoRunning ? 'aktiv' : 'offline'}
{layouts.length > 0 && (
LAYOUT
onPickLayout(e.target.value)}>
— keines
{layouts.map(l => {l} )}
)}
setDialog({ mode: 'new' })}>
Neues Projekt
{ setSettingsTab('rhino'); setSettingsOpen(true) }}
title="Einstellungen">
setView('recent')}>
Kürzlich
setView('all')}>
Alle Projekte
{recent.length > 0 && {recent.length} }
{recent.length > 0 && (
setQuery(e.target.value)} />
{query && (
setQuery('')}
title="Suche zurücksetzen">
)}
)}
{view === 'all' && (
Sortieren
setSortBy(e.target.value)}>
Zuletzt geöffnet
Name
Erstellt
{tags.length > 0 && (
setGroupByTag(e.target.checked)} />
Nach Tags gruppieren
)}
)}
{view === 'all' && tags.length > 0 && !groupByTag && (
Filter:
{tags.map(t => {
const active = tagFilter.has(t.id)
return (
toggleTagFilter(t.id)}>
{t.name}
)
})}
{tagFilter.size > 0 && (
setTagFilter(new Set())}>
Zurücksetzen
)}
)}
{recent.length === 0 ? (
Noch leer
Lege dein erstes Dossier-Projekt an —
wähle eine bestehende
.3dm oder erstelle eine neue.
setDialog({ mode: 'new' })}>
Neues Projekt
) : view === 'recent' ? (
) : groupByTag && allGrouped ? (
{allGrouped.map(([tagId, ps]) => {
const tag = tagId === '__untagged__' ? null : tags.find(t => t.id === tagId)
return (
{tag ? (
{tag.name}
) : (
Ohne Tag
)}
{ps.length}
{ps.map(p =>
)}
)
})}
) : allSorted.length === 0 ? (
Keine Treffer
Kein Projekt entspricht den Filterkriterien.
) : (
)}
{dialog && (
setDialog(null)}
onSave={saveProject}
/>
)}
{settingsOpen && (
{ setSettingsOpen(false); invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {}) }}
/>
)}
{snapshotToast && (
Snapshot erstellt: {snapshotToast.split('/').pop()}
)}
)
}
function ProjectCard({ p, tags, fileMeta, openMap, thumbMap = {}, busy,
onOpen, onEdit, onReveal, onSnapshot, onTogglePin, onRemove }) {
const [expanded, setExpanded] = useState(false)
const meta = fileMeta[p.path]
const extras = p.extraFiles || []
const thumb = thumbMap[p.path]
// Open-Status: PRIMARY oder einer der Extras offen
const isOpen = !!openMap[p.path] || extras.some(f => openMap[f])
const projTags = (p.tagIds || [])
.map(id => tags.find(t => t.id === id))
.filter(Boolean)
return (
onOpen(p)}>
{thumb ? (
) : (
)}
{p.name}
{p.modules?.length || 0} Module
{extras.length > 0 && (
setExpanded(x => !x)}
title={expanded ? 'Dateien einklappen' : 'Alle Dateien anzeigen'}>
+{extras.length} Datei{extras.length === 1 ? '' : 'en'}
)}
{projTags.map(t => (
{t.name}
))}
{p.path}
{isOpen && }
{formatRelative(p.lastOpened)}
{meta?.exists && (
{formatFileSize(meta.size)}
{meta.modifiedIso && ` · ${formatRelative(meta.modifiedIso)}`}
)}
{meta && !meta.exists && (
Datei fehlt
)}
onTogglePin(p)}
title={p.pinned ? 'Pin entfernen' : 'Anpinnen'}>
onSnapshot(p)}
title="Snapshot erstellen (Kopie mit Zeitstempel in _snapshots/)">
onReveal(p)}
title="Im Finder zeigen">
onEdit(p)}
title="Bearbeiten">
onOpen(p)}>
Öffnen
onRemove(p)}
title="Aus Liste entfernen">
{expanded && extras.length > 0 && (
onOpen(p)} />
{extras.map(f => (
onOpen(p, f)} />
))}
)}
)
}
function FileRow({ path, isPrimary = false, isOpen, busy, onOpen }) {
const name = path.split('/').pop()
return (
{isOpen && }
{isPrimary && Hauptdatei }
{name}
{path}
Öffnen
)
}
function SettingsDialog({ initialTab = 'rhino', onClose }) {
const [tab, setTab] = useState(initialTab)
return (
{ if (e.target === e.currentTarget) onClose() }}>
{tab === 'rhino' && }
{tab === 'view' && }
{tab === 'ebenen' && }
{tab === 'tags' && }
{tab === 'layout' && }
{tab === 'updates' && }
)
}
function TabBtn({ active, onClick, children }) {
return (
{children}
)
}
function RhinoSettings() {
const [rhinoApp, setRhinoApp] = useState('')
const [templatePath, setTemplatePath] = useState('')
const [autoLoadPlugin, setAutoLoadPlugin] = useState(false)
const [pluginStartupPath, setPluginStartupPath] = useState('')
const [defaultStartup, setDefaultStartup] = useState('')
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [pluginLoading, setPluginLoading] = useState(false)
useEffect(() => {
Promise.all([
invoke('read_settings').catch(() => ({})),
invoke('get_default_plugin_startup_path').catch(() => ''),
]).then(([s, def]) => {
setRhinoApp(s.rhinoApp || 'Rhinoceros 8')
setTemplatePath(s.templatePath || '')
setAutoLoadPlugin(!!s.autoLoadPlugin)
setPluginStartupPath(s.pluginStartupPath || '')
setDefaultStartup(def || '')
setLoaded(true)
})
}, [])
const pickRhino = async () => {
const sel = await openDialog({
title: 'Rhino-App waehlen',
filters: [{ name: 'Anwendung', extensions: ['app'] }],
defaultPath: '/Applications',
})
if (typeof sel === 'string') setRhinoApp(sel)
}
const pickTemplate = async () => {
const sel = await openDialog({
title: 'Template-3dm waehlen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel === 'string') setTemplatePath(sel)
}
const pickStartup = async () => {
const sel = await openDialog({
title: 'startup.py des Dossier-Plugins',
filters: [{ name: 'Python', extensions: ['py'] }],
})
if (typeof sel === 'string') setPluginStartupPath(sel)
}
const clearTemplate = () => setTemplatePath('')
const useDefault = () => setRhinoApp('Rhinoceros 8')
const useDefaultStartup = () => setPluginStartupPath('')
const loadNow = async () => {
try {
setPluginLoading(true)
await invoke('trigger_plugin_load_now')
setTimeout(() => setPluginLoading(false), 4000)
} catch (ex) {
setPluginLoading(false)
alert(`Plugin-Load: ${ex}`)
}
}
const save = async () => {
try {
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: {
...cur,
rhinoApp: rhinoApp.trim() || 'Rhinoceros 8',
templatePath: templatePath.trim() || null,
autoLoadPlugin: !!autoLoadPlugin,
pluginStartupPath: pluginStartupPath.trim() || null,
},
})
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
if (!loaded) return null
return (
<>
Rhino-Anwendung
setRhinoApp(e.target.value)}
placeholder="Rhinoceros 8" />
Wählen…
App-Name (in /Applications gesucht)
oder absoluter Pfad zur .app.
Beispiele
Rhinoceros 8
/Applications/Rhino 8.app
/Applications/Rhino 8 WIP.app
Standard verwenden
{saved && ✓ Gespeichert }
Speichern
>
)
}
function TagsSettings() {
const [tags, setTags] = useState([])
const [loaded, setLoaded] = useState(false)
const [draftName, setDraftName] = useState('')
const [draftColor, setDraftColor] = useState('#5fa896')
useEffect(() => {
invoke('read_settings').then(s => {
setTags(s?.tags || [])
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
const persist = async (next) => {
setTags(next)
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: { ...cur, tags: next },
}).catch(ex => alert(`Tags speichern: ${ex}`))
}
const addTag = () => {
const name = draftName.trim()
if (!name) return
if (tags.some(t => t.name.toLowerCase() === name.toLowerCase())) {
alert('Es gibt schon einen Tag mit diesem Namen.')
return
}
const next = [...tags, { id: newTagId(), name, color: draftColor }]
persist(next)
setDraftName('')
}
const removeTag = (id) => {
if (!confirm('Tag löschen? Projekte mit diesem Tag verlieren ihn.')) return
persist(tags.filter(t => t.id !== id))
}
const updateTag = (id, patch) => {
persist(tags.map(t => t.id === id ? { ...t, ...patch } : t))
}
if (!loaded) return null
return (
<>
Vorhandene Tags ({tags.length})
{tags.length === 0 ? (
Noch keine Tags. Mit dem Formular oben kannst du Kategorien
anlegen — Name + Farbe. Sie sind dann im Projekt-Dialog wählbar
und filtern die Projektliste.
) : (
)}
>
)
}
// Mapping Frontend-Key -> Label. Reihenfolge spielt fuer die UI eine Rolle.
const VIEW_COLOR_FIELDS = [
{ key: 'background', label: 'Viewport-Hintergrund', default: '#f0f0f0' },
{ key: 'gridLine', label: 'Raster (Minor)', default: '#bcbcbc' },
{ key: 'gridMajor', label: 'Raster (Major)', default: '#9e9e9e' },
{ key: 'gridX', label: 'Raster X-Achse', default: '#c84040' },
{ key: 'gridY', label: 'Raster Y-Achse', default: '#40a040' },
{ key: 'worldX', label: 'Welt X-Achse', default: '#ff0000' },
{ key: 'worldY', label: 'Welt Y-Achse', default: '#00b000' },
{ key: 'worldZ', label: 'Welt Z-Achse', default: '#0080ff' },
]
// Default-Schema fuer neue Projekte. Muss im Sync mit INITIAL_EBENEN in
// src/App.jsx des Rhino-Panels bleiben — wenn der User keine eigene Schema
// definiert, schickt das Plugin diese hier als FIRST_RUN-Default.
const DEFAULT_LAYER_SCHEMA = [
{ code: '00', name: 'RASTER', color: '#484850', lw: 0.13 },
{ code: '01', name: 'VERMESSUNG', color: '#707078', lw: 0.18 },
{ code: '10', name: 'SITUATION', color: '#909090', lw: 0.18 },
{ code: '11', name: 'STRASSE', color: '#a89070', lw: 0.18 },
{ code: '12', name: 'GEBAEUDE', color: '#888888', lw: 0.25 },
{ code: '13', name: 'BAEUME', color: '#50a050', lw: 0.13 },
{ code: '14', name: 'HOEHENLINIEN', color: '#909050', lw: 0.18 },
{ code: '20', name: 'WAENDE', color: '#0a0a0a', lw: 0.50 },
{ code: '21', name: 'TUEREN_FENSTER', color: '#5080c8', lw: 0.25 },
{ code: '22', name: 'MOEBEL', color: '#909090', lw: 0.13 },
{ code: '25', name: 'STUETZEN', color: '#c87050', lw: 0.50 },
{ code: '30', name: 'DECKEN', color: '#605850', lw: 0.35 },
{ code: '31', name: 'DAECHER', color: '#7a4a3a', lw: 0.35 },
{ code: '35', name: 'TRAEGER', color: '#a87858', lw: 0.50 },
{ code: '50', name: 'TEXT', color: '#d0d0d0', lw: 0.13 },
{ code: '60', name: 'PLANGRAFIK', color: '#c0a040', lw: 0.13 },
{ code: '90', name: 'REFERENZEN', color: '#585860', lw: 0.13 },
{ code: '99', name: 'KONSTRUKTION', color: '#404048', lw: 0.13 },
]
// Built-in Presets — nicht in den Settings gespeichert, immer verfuegbar.
const BUILTIN_PRESETS = [
{
id: 'builtin:rhino',
name: 'Rhino-Standard',
builtin: true,
colors: {}, // leere Colors = Rhino-Defaults nicht ueberschreiben
},
{
id: 'builtin:dossier',
name: 'Dossier-Standard',
builtin: true,
colors: {
background: '#1c2421',
gridLine: '#2a3330',
gridMajor: '#3a4540',
gridX: '#7a4a3a',
gridY: '#4a7a5a',
worldX: '#c84a3a',
worldY: '#5fa896',
worldZ: '#4a8ac8',
},
},
]
function ViewSettings() {
const [colors, setColors] = useState({})
const [autoApply, setAutoApply] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [importBusy, setImportBusy] = useState(false)
const [customPresets, setCustomPresets] = useState([])
const allPresets = useMemo(() => [...BUILTIN_PRESETS, ...customPresets], [customPresets])
useEffect(() => {
invoke('read_dossier_settings').then(ds => {
setColors(ds?.viewportColors || {})
setAutoApply(!!ds?.autoApplyViewColors)
setLoaded(true)
}).catch(() => setLoaded(true))
invoke('read_settings').then(s => setCustomPresets(s?.viewColorPresets || []))
.catch(() => {})
}, [])
// Match: welcher Preset entspricht den aktuellen Colors exakt?
const currentPresetId = useMemo(() => {
const keys = VIEW_COLOR_FIELDS.map(f => f.key)
const norm = (obj) => keys.map(k => (obj?.[k] || '').toLowerCase()).join('|')
const cur = norm(colors)
return allPresets.find(p => norm(p.colors) === cur)?.id || ''
}, [colors, allPresets])
const applyPreset = (presetId) => {
const p = allPresets.find(pp => pp.id === presetId)
if (!p) return
const next = { ...p.colors }
setColors(next)
persist(next, autoApply)
}
const persistCustomPresets = async (next) => {
setCustomPresets(next)
const cur = await invoke('read_settings').catch(() => ({}))
await invoke('save_settings', {
settings: { ...cur, viewColorPresets: next },
}).catch(ex => console.warn('preset save:', ex))
}
const saveAsPreset = async () => {
const name = prompt('Name für dieses Preset:')
if (!name || !name.trim()) return
const id = 'custom_' + Math.random().toString(36).slice(2, 9)
persistCustomPresets([...customPresets, { id, name: name.trim(), colors }])
}
const deletePreset = async (id) => {
const p = customPresets.find(pp => pp.id === id)
if (!p) return
if (!confirm(`Preset „${p.name}" löschen?`)) return
persistCustomPresets(customPresets.filter(pp => pp.id !== id))
}
const exportPreset = async (id) => {
const p = allPresets.find(pp => pp.id === id) || { name: 'export', colors }
const payload = JSON.stringify({
dossier: { kind: 'view-color-preset', version: 1 },
name: p.name, colors: p.colors,
}, null, 2)
const target = await saveDialog({
title: 'Preset exportieren',
defaultPath: `dossier-view-${p.name.replace(/\s+/g, '-').toLowerCase()}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof target !== 'string') return
try {
await invoke('write_text_file', { path: target, content: payload })
alert(`Preset exportiert:\n${target}`)
} catch (ex) {
alert(`Export: ${ex}`)
}
}
const importPreset = async () => {
const sel = await openDialog({
title: 'Preset importieren',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof sel !== 'string') return
try {
const raw = await invoke('read_text_file', { path: sel })
const parsed = JSON.parse(raw)
if (!parsed?.colors || !parsed?.name) {
alert('Format unbekannt — JSON muss "name" und "colors" enthalten.')
return
}
const id = 'custom_' + Math.random().toString(36).slice(2, 9)
persistCustomPresets([...customPresets, { id, name: parsed.name, colors: parsed.colors }])
alert(`Preset „${parsed.name}" importiert.`)
} catch (ex) {
alert(`Import: ${ex}`)
}
}
// Lokale Aenderung sofort persistieren, ohne Apply
const persist = async (nextColors, nextAuto) => {
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: {
...cur,
viewportColors: nextColors,
autoApplyViewColors: !!nextAuto,
},
}).catch(ex => console.warn('view-colors save:', ex))
}
const setColor = (key, value) => {
const next = { ...colors, [key]: value || null }
setColors(next)
persist(next, autoApply)
}
const setAutoApplyFlag = (v) => {
setAutoApply(v)
persist(colors, v)
}
const applyNow = async () => {
try {
await invoke('apply_view_colors_now')
setSaved(true)
setTimeout(() => setSaved(false), 1800)
} catch (ex) {
alert(`Anwenden fehlgeschlagen: ${ex}`)
}
}
const resetAll = () => {
if (!confirm('Alle Farben zurücksetzen?')) return
setColors({})
persist({}, autoApply)
}
const importDisplayMode = async () => {
const sel = await openDialog({
title: 'Display-Mode (.ini) wählen',
filters: [{ name: 'Rhino Display Mode', extensions: ['ini'] }],
})
if (typeof sel !== 'string') return
try {
setImportBusy(true)
await invoke('queue_import_display_mode', { path: sel })
alert(`Importiert. Rhino-Plugin übernimmt beim nächsten Idle-Tick (~1 s).\n\n${sel}`)
} catch (ex) {
alert(`Import fehlgeschlagen: ${ex}`)
} finally {
setImportBusy(false)
}
}
if (!loaded) return null
return (
<>
Preset
applyPreset(e.target.value)}
style={{ flex: 1 }}>
— Eigene Konfiguration
{BUILTIN_PRESETS.map(p =>
{p.name}
)}
{customPresets.length > 0 && (
{customPresets.map(p =>
{p.name}
)}
)}
Speichern als…
{currentPresetId && currentPresetId.startsWith('custom_') && (
deletePreset(currentPresetId)}
title="Aktives Preset löschen">
)}
Preset importieren…
{currentPresetId && (
exportPreset(currentPresetId)}>
Aktives Preset exportieren…
)}
Viewport-Farben
{VIEW_COLOR_FIELDS.map(f => (
setColor(f.key, e.target.value)}
className="color-input" />
{f.label}
{colors[f.key] || '— Rhino-Default'}
{colors[f.key] && (
setColor(f.key, null)}
title="Auf Rhino-Default zurücksetzen">
)}
))}
setAutoApplyFlag(e.target.checked)}
style={{ width: 14, height: 14 }} />
Beim Rhino-Start automatisch anwenden
{saved && ✓ Angewendet }
Alle zurücksetzen
Jetzt anwenden
Display-Modes importieren
Importiert eine Display-Mode .ini-Datei in Rhino.
Die Stile lassen sich in Rhinos Options → Display Modes →
Export erzeugen. So baust du dir deine Dossier-Stile
(Hardline, Soft-Shaded, Plan-1:50, etc.) einmal und kannst sie auf
jedem Mac in zwei Klicks importieren.
{importBusy ? 'Importiere…' : '.ini-Datei wählen'}
>
)
}
function EbenenSchemaSettings() {
const [schema, setSchema] = useState([])
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [importing, setImporting] = useState(false)
const reload = useCallback(() => {
return invoke('read_dossier_settings').then(ds => {
const s = (ds?.layerSchema && ds.layerSchema.length)
? ds.layerSchema
: DEFAULT_LAYER_SCHEMA
setSchema(s)
setLoaded(true)
})
}, [])
useEffect(() => {
reload().catch(() => { setSchema(DEFAULT_LAYER_SCHEMA); setLoaded(true) })
}, [reload])
const persist = async (next) => {
setSchema(next)
const cur = await invoke('read_dossier_settings').catch(() => ({}))
await invoke('save_dossier_settings', {
settings: { ...cur, layerSchema: next },
}).catch(ex => console.warn('schema save:', ex))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
}
const updateRow = (i, patch) => {
persist(schema.map((row, idx) => idx === i ? { ...row, ...patch } : row))
}
const addRow = () => {
// Naechste freie Code aus dem hoechsten + 1
const max = schema.reduce((m, r) => Math.max(m, parseInt(r.code, 10) || 0), 0)
const code = String(max + 1).padStart(2, '0')
persist([...schema, { code, name: 'NEU', color: '#888888', lw: 0.13 }])
}
const removeRow = (i) => {
if (!confirm(`Ebene „${schema[i].code}_${schema[i].name}" entfernen?`)) return
persist(schema.filter((_, idx) => idx !== i))
}
const moveRow = (i, dir) => {
const j = i + dir
if (j < 0 || j >= schema.length) return
const next = [...schema]
;[next[i], next[j]] = [next[j], next[i]]
persist(next)
}
const resetToDefault = () => {
if (!confirm('Schema auf Dossier-Standard zurücksetzen?')) return
persist(DEFAULT_LAYER_SCHEMA)
}
const importFromRhino = async () => {
if (!confirm('Aktuelles Schema mit den Ebenen aus dem laufenden Rhino-Projekt überschreiben?')) return
try {
setImporting(true)
await invoke('request_ebenen_export')
// Plugin verarbeitet im naechsten Idle-Tick (~1s). Wir pollen kurz.
for (let i = 0; i < 8; i++) {
await new Promise(r => setTimeout(r, 600))
const ds = await invoke('read_dossier_settings').catch(() => null)
if (ds && !ds.pendingExportEbenen) {
await reload()
setImporting(false)
return
}
}
setImporting(false)
alert('Rhino hat nicht innerhalb von 5 s reagiert. Läuft Rhino mit aktivem Dossier-Plugin?')
} catch (ex) {
setImporting(false)
alert(`Import: ${ex}`)
}
}
const exportSchema = async () => {
const payload = JSON.stringify({
dossier: { kind: 'layer-schema', version: 1 },
schema,
}, null, 2)
const target = await saveDialog({
title: 'Schema exportieren',
defaultPath: 'dossier-ebenen-schema.json',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof target !== 'string') return
try {
await invoke('write_text_file', { path: target, content: payload })
alert(`Schema exportiert:\n${target}`)
} catch (ex) {
alert(`Export: ${ex}`)
}
}
const importSchema = async () => {
const sel = await openDialog({
title: 'Schema importieren',
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (typeof sel !== 'string') return
try {
const raw = await invoke('read_text_file', { path: sel })
const parsed = JSON.parse(raw)
const next = parsed?.schema || parsed
if (!Array.isArray(next)) {
alert('Format unbekannt — JSON muss ein Array mit Layer-Templates enthalten.')
return
}
// Validierung: jeder Eintrag braucht code, name, color, lw
const clean = next.filter(r => r.code && r.name && r.color !== undefined && r.lw !== undefined)
if (clean.length === 0) {
alert('Keine gültigen Einträge im Import gefunden.')
return
}
persist(clean)
alert(`${clean.length} Ebenen importiert.`)
} catch (ex) {
alert(`Import: ${ex}`)
}
}
if (!loaded) return null
return (
<>
Default-Sublayer für neue Projekte
Diese Liste wird bei Neues Projekt in Rhino als Anfangs-Schema
verwendet. Pro Sublayer: Code (für die Hierarchie 01_WAND, …), Name,
Farbe und Stiftdicke. Reihenfolge entspricht der Anzeige in Rhinos
Layer-Panel.
Code
Name
Farbe
Stift (mm)
{schema.map((row, i) => (
))}
Zeile hinzufügen
{importing ? 'Lese aus Rhino …' : 'Aus laufendem Rhino importieren'}
Aus JSON…
Exportieren…
Auf Dossier-Standard zurücksetzen
{saved && ✓ Gespeichert }
>
)
}
function LayoutSettings() {
const [layouts, setLayouts] = useState([])
const [selected, setSelected] = useState('')
const [autoApply, setAutoApply] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
Promise.all([
invoke('list_window_layouts').catch(() => []),
invoke('read_dossier_settings').catch(() => ({})),
]).then(([list, ds]) => {
setLayouts(list || [])
setSelected(ds?.windowLayout || '')
setAutoApply(!!ds?.autoApplyLayout)
setLoaded(true)
})
}, [])
const save = async () => {
try {
await invoke('save_dossier_settings', {
settings: { windowLayout: selected || null, autoApplyLayout: autoApply },
})
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (ex) {
alert(`Speichern fehlgeschlagen: ${ex}`)
}
}
if (!loaded) return null
return (
Standard-Layout für Dossier
{layouts.length === 0 ? (
Keine Rhino Window-Layouts gefunden. Erstelle erst eines in Rhino via
Window → Window Layouts → Save , dann hier auswählen.
) : (
setSelected(e.target.value)}
style={{
fontFamily: 'inherit', fontSize: 13,
background: 'var(--bg-base)', color: 'var(--text)',
border: '1px solid var(--border)', borderRadius: 6,
padding: '6px 10px',
}}>
(keines)
{layouts.map(l => {l} )}
)}
setAutoApply(e.target.checked)}
style={{ width: 14, height: 14 }} />
Beim Rhino-Start automatisch anwenden
Dossier liest diese Einstellung beim Öffnen eines Projekts und ruft Rhinos
Window-Layout auf. So spart man sich manuelles Wiederherstellen von Panel-Anordnungen.
{saved && ✓ Gespeichert }
Speichern
)
}
function UpdatesSettings() {
const [version, setVersion] = useState('')
const [lastCheck, setLastCheck] = useState(() => getLastUpdateCheck())
const [state, setState] = useState('idle')
const [update, setUpdate] = useState(null)
const [error, setError] = useState(null)
const [downloaded, setDownloaded] = useState(0)
const [total, setTotal] = useState(0)
useEffect(() => {
if (!isTauri()) return
import('@tauri-apps/api/app').then(({ getVersion }) => {
getVersion().then(setVersion).catch(() => {})
})
}, [])
const runCheck = useCallback(async () => {
setError(null)
if (!isTauri()) {
setState('not-tauri')
return
}
setState('checking')
try {
const res = await checkForAppUpdate({ respectSkip: false })
setLastCheck(getLastUpdateCheck())
if (res.available) {
setUpdate(res.update)
setState('available')
} else {
setUpdate(null)
setState('no-update')
}
} catch (e) {
console.error('Update-Check fehlgeschlagen:', e)
setError(String(e?.message || e))
setState('idle')
}
}, [])
const install = async () => {
if (!update) return
setError(null)
try {
setState('downloading')
setDownloaded(0)
setTotal(0)
await installAppUpdate(update, (event) => {
if (event.event === 'Started') {
setTotal(event.data.contentLength || 0)
} else if (event.event === 'Progress') {
setDownloaded((d) => d + (event.data.chunkLength || 0))
} else if (event.event === 'Finished') {
setState('installing')
}
})
} catch (e) {
setError(String(e?.message || e))
setState('available')
}
}
const skipVersion = () => {
skipUpdateVersion(update?.version)
setUpdate(null)
setState('idle')
}
const isBusy = state === 'downloading' || state === 'installing'
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null
return (
Aktuelle Version
{version || '—'}
Letzte Prüfung
{formatLastCheck(lastCheck)}
{state === 'checking' ? 'Wird geprüft …' : 'Nach Updates suchen'}
{state === 'no-update' && (
✓ Dossier ist auf dem neuesten Stand.
)}
{state === 'available' && update && (
UPDATE VERFÜGBAR
Dossier {update.version}
{update.body && (
{update.body}
)}
{isBusy ? 'Bitte warten …' : 'Installieren und neu starten'}
Diese Version überspringen
)}
{isBusy && (
{state === 'downloading'
? (pct !== null ? `Wird heruntergeladen … ${pct}%` : 'Wird heruntergeladen …')
: 'Wird installiert …'}
)}
{state === 'not-tauri' && (
Updates sind nur in der Desktop-App verfügbar.
)}
{error && (
{error}
)}
Updates werden automatisch beim Start der App geprüft.
)
}
function ProjectDialog({ mode, project, modules, onCancel, onSave }) {
const [name, setName] = useState(project?.name || '')
const [path, setPath] = useState(project?.path || '')
const [picked, setPicked] = useState(new Set(project?.modules || ['ebenen', 'oberleiste']))
const [useTemplate, setUseTemplate] = useState(true) // nur bei mode='new' wirksam
const [hasTemplate, setHasTemplate] = useState(false)
const [windowLayout, setWindowLayout] = useState(project?.windowLayout || '')
const [layouts, setLayouts] = useState([])
const [tagIds, setTagIds] = useState(new Set(project?.tagIds || []))
const [allTags, setAllTags] = useState([])
const [extraFiles, setExtraFiles] = useState(project?.extraFiles || [])
useEffect(() => {
invoke('read_settings').then(s => {
if (mode === 'new') setHasTemplate(!!s?.templatePath)
setAllTags(s?.tags || [])
}).catch(() => {})
invoke('list_window_layouts').then(setLayouts).catch(() => {})
}, [mode])
const toggleTag = (id) => {
setTagIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const addExtraFile = async () => {
const sel = await openDialog({
title: 'Weitere Projektdatei (.3dm) wählen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel !== 'string') return
if (sel === path) { alert('Das ist bereits die Hauptdatei.'); return }
if (extraFiles.includes(sel)) return
setExtraFiles([...extraFiles, sel])
}
const removeExtraFile = (p) => {
setExtraFiles(extraFiles.filter(x => x !== p))
}
const effective = useMemo(() => resolveDeps(picked, modules), [picked, modules])
const locked = useMemo(() => {
const out = new Set()
for (const id of effective) {
if (!picked.has(id)) out.add(id)
}
return out
}, [picked, effective, modules])
const toggle = (id) => {
if (locked.has(id)) return
setPicked(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const pickFile = async () => {
// Bei "neu" wollen wir wirklich einen NEUEN Pfad — saveDialog zeigt das
// dem User klar an ("Save As") und akzeptiert auch noch nicht existierende
// Dateien. Bei "edit" zeigen wir openDialog (Datei muss existieren).
const sel = mode === 'new'
? await saveDialog({
title: '3dm-Datei anlegen',
defaultPath: name.trim() ? `${name.trim()}.3dm` : 'untitled.3dm',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
: await openDialog({
title: '3dm-Datei waehlen',
filters: [{ name: 'Rhino', extensions: ['3dm'] }],
})
if (typeof sel === 'string') setPath(sel)
}
return (
{ if (e.target === e.currentTarget) onCancel() }}>
{mode === 'new' ? 'Neues Projekt' : 'Projekt bearbeiten'}
Projekt-Name
setName(e.target.value)}
placeholder="z.B. Wohnhaus Brunner" autoFocus />
Zusätzliche Projektdateien (.3dm)
{extraFiles.length === 0 ? (
Keine. Klick „Datei hinzufügen", wenn dein Projekt aus mehreren
.3dm-Dateien besteht (z.B. Plan + Details + Präsentation).
) : (
{extraFiles.map(f => (
{f.split('/').pop()}
removeExtraFile(f)}
title="Entfernen">
))}
)}
Datei hinzufügen
Tags
{allTags.length === 0 ? (
Noch keine Tags definiert. In den Einstellungen → Tags
kannst du eigene Kategorien (Name + Farbe) anlegen.
) : (
{allTags.map(t => {
const active = tagIds.has(t.id)
return (
toggleTag(t.id)}>
{t.name}
)
})}
)}
Window-Layout für dieses Projekt
{layouts.length === 0 ? (
Keine Rhino-Window-Layouts gefunden. In Rhino:
Window → Window Layouts → Save…
) : (
setWindowLayout(e.target.value)}
style={{ width: '100%' }}>
— Standard aus Einstellungen verwenden
{layouts.map(l => {l} )}
)}
Wird beim Öffnen dieses Projekts in Rhino angewendet.
Überschreibt das globale Default-Layout.
Module ({[...effective].length} aktiv)
{modules.map(m => {
const isActive = effective.has(m.id)
const isLocked = locked.has(m.id)
return (
toggle(m.id)}
title={isLocked ? 'Wird von einem anderen Modul gebraucht' : ''}
>
{isActive ? (isLocked ? '🔒' : '✓') : ''}
{m.name}
{m.description}
{(m.dependsOn || []).length > 0 && (
braucht: {m.dependsOn.join(', ')}
)}
)
})}
Abbrechen
onSave({
name, path,
modules: [...effective],
useTemplate: mode === 'new' && useTemplate && hasTemplate,
windowLayout: windowLayout || null,
tagIds: [...tagIds],
extraFiles,
})}>
{mode === 'new' ? 'Anlegen' : 'Speichern'}
)
}