Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+190 -54
View File
@@ -11,7 +11,7 @@ import (
"task/internal/project"
)
// ProjectEdit field indices.
// ProjectEdit field indices (backend picker is separate).
const (
FieldRemoteURL = iota
FieldToken
@@ -19,6 +19,12 @@ const (
ProjectEditFieldCount
)
var backendOptions = []string{
project.BackendNone,
project.BackendYouTrack,
project.BackendTaskMaster,
}
// ProjectEditStyles styles the project edit form.
type ProjectEditStyles struct {
Label lipgloss.Style
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
}
}
// ProjectEditForm is a multi-field editor for YouTrack project settings.
// ProjectEditForm is a multi-field editor for sync backend settings.
type ProjectEditForm struct {
ProjectID string
Backend string
BackendIdx int
FocusBackend bool // true when selecting backend
Inputs []textinput.Model
Focus int
Styles ProjectEditStyles
@@ -67,34 +76,82 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
return ti
}
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false)
backend := meta.EffectiveBackend()
if backend == "" {
backend = project.BackendNone
}
bIdx := 0
for i, b := range backendOptions {
if b == backend {
bIdx = i
break
}
}
hadToken := false
if meta.YouTrack != nil {
yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
if yt.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
switch backend {
case project.BackendTaskMaster:
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
inputs[FieldToken] = mk("tm_… project token", 512, true)
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
if meta.TaskMaster != nil {
tm := meta.TaskMaster
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
inputs[FieldProjectKey].SetValue(tm.ProjectID)
if tm.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
default:
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false)
if meta.YouTrack != nil {
yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
if yt.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
}
f := ProjectEditForm{
ProjectID: projectID,
Inputs: inputs,
Focus: FieldRemoteURL,
Styles: styles,
Width: width,
hadToken: hadToken,
ProjectID: projectID,
Backend: backend,
BackendIdx: bIdx,
FocusBackend: true,
Inputs: inputs,
Focus: FieldRemoteURL,
Styles: styles,
Width: width,
hadToken: hadToken,
}
f.focusIndex(FieldRemoteURL)
return f
}
func (f *ProjectEditForm) applyBackendPlaceholders() {
f.Backend = backendOptions[f.BackendIdx]
switch f.Backend {
case project.BackendTaskMaster:
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
f.Inputs[FieldToken].Placeholder = "tm_… project token"
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
case project.BackendYouTrack:
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
f.Inputs[FieldToken].Placeholder = "permanent token"
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
default:
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
f.Inputs[FieldToken].Placeholder = "(unused)"
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
}
}
func (f *ProjectEditForm) focusIndex(i int) {
f.FocusBackend = false
f.Focus = i
for idx := range f.Inputs {
if idx == i {
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
// NextField moves focus to the next field.
func (f *ProjectEditForm) NextField() {
if f.FocusBackend {
if f.Backend == project.BackendNone {
return
}
f.focusIndex(FieldRemoteURL)
return
}
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *ProjectEditForm) PrevField() {
if f.FocusBackend {
return
}
if f.Focus == FieldRemoteURL {
f.blurInputs()
f.FocusBackend = true
return
}
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
}
func (f *ProjectEditForm) blurInputs() {
for idx := range f.Inputs {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
// CycleBackend changes the selected backend when focusing the picker.
func (f *ProjectEditForm) CycleBackend(delta int) {
if !f.FocusBackend {
return
}
n := len(backendOptions)
f.BackendIdx = (f.BackendIdx + delta + n) % n
f.applyBackendPlaceholders()
}
// Update forwards a message to the focused text input.
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
if f.FocusBackend || f.Backend == project.BackendNone {
return nil
}
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
// Meta builds the Meta to persist. Empty token keeps the previous token.
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
backend := backendOptions[f.BackendIdx]
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
if baseURL == "" && token == "" && key == "" {
return project.Meta{YouTrack: nil}
switch backend {
case project.BackendNone:
return project.Meta{Backend: project.BackendNone}
case project.BackendTaskMaster:
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
if token == "" && previous.TaskMaster != nil {
tm.Token = previous.TaskMaster.Token
}
if baseURL == "" && token == "" && key == "" {
return project.Meta{Backend: project.BackendNone}
}
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
default:
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token
}
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query
}
if baseURL == "" && token == "" && key == "" {
return project.Meta{Backend: project.BackendNone}
}
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: key,
Token: token,
}
if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token
}
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query
}
return project.Meta{YouTrack: yt}
}
// Validate ensures partial YouTrack configs are rejected.
// Validate ensures partial configs are rejected.
func (f ProjectEditForm) Validate(previous project.Meta) error {
meta := f.Meta(previous)
if meta.YouTrack == nil {
switch meta.EffectiveBackend() {
case project.BackendNone:
return nil
case project.BackendTaskMaster:
tm := meta.TaskMaster
if tm == nil {
return nil
}
if !tm.Configured() {
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
}
return nil
default:
yt := meta.YouTrack
if yt == nil {
return nil
}
if !yt.Configured() {
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
}
return nil
}
yt := meta.YouTrack
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
if any && !yt.Configured() {
missing := []string{}
if yt.BaseURL == "" {
missing = append(missing, "remote URL")
}
if yt.Token == "" {
missing = append(missing, "token")
}
if yt.ProjectKey == "" {
missing = append(missing, "project key")
}
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
}
return nil
}
// View renders the form.
func (f ProjectEditForm) View() string {
labels := []string{"Remote URL", "Token", "Project key"}
var b strings.Builder
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
b.WriteString("\n\n")
backendLine := "Backend: "
for i, opt := range backendOptions {
label := opt
if i == f.BackendIdx {
if f.FocusBackend {
label = f.Styles.Focused.Render("[" + opt + "]")
} else {
label = f.Styles.Focused.Render(opt)
}
} else {
label = f.Styles.Blurred.Render(opt)
}
if i > 0 {
backendLine += " "
}
backendLine += label
}
b.WriteString(backendLine)
b.WriteString("\n\n")
if f.Backend == project.BackendNone {
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
b.WriteString("\n")
return b.String()
}
labels := []string{"Remote URL", "Token", "Project key"}
if f.Backend == project.BackendTaskMaster {
labels = []string{"Server URL", "Project token", "Remote project ID"}
}
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label + ":"))
b.WriteString("\n")