feat(office): table detail panes and Alt+1/2/3 layout toggles
Add spreadsheet-style list, split detail pane with forms and document preview, and Alt+1/2/3 to show/hide panes with even width distribution. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+373
-222
@@ -2,7 +2,6 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
type listLoadedMsg struct {
|
||||
@@ -20,9 +18,18 @@ type listLoadedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type previewLoadedMsg struct {
|
||||
text string
|
||||
err error
|
||||
type detailLoadedMsg struct {
|
||||
item model.Item
|
||||
document bool
|
||||
fields model.FormFields
|
||||
markdown string
|
||||
err error
|
||||
}
|
||||
|
||||
type detailSavedMsg struct {
|
||||
item model.Item
|
||||
title, description string
|
||||
err error
|
||||
}
|
||||
|
||||
type actionDoneMsg struct {
|
||||
@@ -44,7 +51,6 @@ type Model struct {
|
||||
hasList bool
|
||||
items []model.Item
|
||||
selection *model.Selection
|
||||
listIdx int
|
||||
focus model.FocusPane
|
||||
width int
|
||||
height int
|
||||
@@ -52,29 +58,36 @@ type Model struct {
|
||||
err string
|
||||
loading bool
|
||||
menuVP viewport.Model
|
||||
listVP viewport.Model
|
||||
previewVP viewport.Model
|
||||
previewMD string
|
||||
// Action menu overlay on list pane
|
||||
actionMode bool
|
||||
actionIdx int
|
||||
itemActions []model.ItemAction
|
||||
listTable DataTable
|
||||
detail DetailPane
|
||||
showMenu bool
|
||||
showList bool
|
||||
showDetail bool
|
||||
}
|
||||
|
||||
// NewModel constructs the TUI with an authenticated client.
|
||||
func NewModel(client *onlyoffice.Client) Model {
|
||||
return Model{
|
||||
h, w := 24, 80
|
||||
m := Model{
|
||||
client: client,
|
||||
loader: &fetch.Loader{Client: client},
|
||||
nav: model.DefaultNavTree(),
|
||||
selection: model.NewSelection(),
|
||||
focus: model.FocusMenu,
|
||||
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
|
||||
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit",
|
||||
height: h,
|
||||
width: w,
|
||||
}
|
||||
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
||||
m.listTable = newDataTable()
|
||||
m.detail = newDetailPane()
|
||||
m.showMenu, m.showList, m.showDetail = true, true, true
|
||||
m.menuVP.MouseWheelEnabled = true
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.loadNavProjectsCmd()
|
||||
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -92,8 +105,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.status = helpText()
|
||||
return m, nil
|
||||
}
|
||||
if m.actionMode && m.focus == model.FocusList {
|
||||
return m.handleActionKey(key)
|
||||
if m.scrollFocusedPane(key) {
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusPreview {
|
||||
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
action := KeyAction(key, m.focus)
|
||||
if action == ActionNone && (key == "up" || key == "k") {
|
||||
@@ -103,36 +121,75 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ActionQuit:
|
||||
return m, tea.Quit
|
||||
case ActionNextPane:
|
||||
m.focus = model.NextFocusPane(m.focus)
|
||||
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionPrevPane:
|
||||
m.focus = model.PrevFocusPane(m.focus)
|
||||
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionMoveUp:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(-1)
|
||||
}
|
||||
m.moveUp()
|
||||
m.syncAllContent()
|
||||
return m, nil
|
||||
m.syncFocusedPane()
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveDown:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(1)
|
||||
}
|
||||
m.moveDown()
|
||||
m.syncAllContent()
|
||||
m.syncFocusedPane()
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveLeft:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.MoveCol(-1)
|
||||
}
|
||||
return m, nil
|
||||
case ActionMoveRight:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.MoveCol(1)
|
||||
}
|
||||
return m, nil
|
||||
case ActionSort:
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.ToggleSort()
|
||||
}
|
||||
return m, nil
|
||||
case ActionToggleSelect:
|
||||
if m.hasList {
|
||||
m.selection.Toggle(&m.items, m.listIdx)
|
||||
m.syncListContent()
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx >= 0 {
|
||||
m.selection.Toggle(&m.items, idx)
|
||||
m.listTable.UpdateItems(m.items)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case ActionOpenActions:
|
||||
if m.hasList && len(m.items) > 0 {
|
||||
m.openActionMenu()
|
||||
m.syncListContent()
|
||||
case ActionFocusDetail:
|
||||
if m.hasList && m.showDetail {
|
||||
m.focus = model.FocusPreview
|
||||
m.syncPaneFocus()
|
||||
m.detail.FocusContent()
|
||||
return m, m.onListRowChanged()
|
||||
}
|
||||
return m, nil
|
||||
case ActionOpenPreview:
|
||||
if m.hasList {
|
||||
m.selection.Toggle(&m.items, m.listIdx)
|
||||
return m, m.loadPreviewCmd()
|
||||
}
|
||||
case ActionToggleMenuPane:
|
||||
m.togglePane(1)
|
||||
return m, nil
|
||||
case ActionToggleListPane:
|
||||
m.togglePane(2)
|
||||
return m, nil
|
||||
case ActionToggleDetailPane:
|
||||
m.togglePane(3)
|
||||
return m, nil
|
||||
case ActionRefresh:
|
||||
if m.hasList {
|
||||
@@ -162,11 +219,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.focus == model.FocusList && key == "enter" && m.hasList {
|
||||
m.openActionMenu()
|
||||
m.syncListContent()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case listLoadedMsg:
|
||||
m.loading = false
|
||||
@@ -174,29 +226,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = msg.err.Error()
|
||||
m.items = nil
|
||||
m.hasList = false
|
||||
m.listTable.Clear()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.items = msg.items
|
||||
m.listSpec = msg.spec
|
||||
m.hasList = true
|
||||
m.listIdx = 0
|
||||
m.listTable.SetData(m.listSpec, m.items)
|
||||
}
|
||||
m.syncListContent()
|
||||
return m, nil
|
||||
return m, m.onListRowChanged()
|
||||
|
||||
case previewLoadedMsg:
|
||||
case detailLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
|
||||
m.err = msg.err.Error()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
m.previewMD = msg.text
|
||||
pw := m.paneLayout()
|
||||
w := pw.Detail - 4
|
||||
if w < 20 {
|
||||
w = 20
|
||||
}
|
||||
if msg.document {
|
||||
m.detail.LoadDocument(msg.item, msg.markdown, w)
|
||||
} else {
|
||||
m.detail.LoadForm(msg.item, msg.fields)
|
||||
}
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
m.err = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case detailSavedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.updateItemAfterSave(msg.item, msg.title, msg.description)
|
||||
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
|
||||
fields.Primary = msg.title
|
||||
fields.Secondary = msg.description
|
||||
m.detail.LoadForm(msg.item, fields)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
m.status = "Saved"
|
||||
m.err = ""
|
||||
}
|
||||
m.syncPreviewContent()
|
||||
return m, nil
|
||||
|
||||
case actionDoneMsg:
|
||||
m.loading = false
|
||||
m.actionMode = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
@@ -215,6 +294,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.syncMenuContent()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.MouseMsg:
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
@@ -222,22 +313,72 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
m.listVP, cmd = m.listVP.Update(msg)
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
m.previewVP, cmd = m.previewVP.Update(msg)
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
switch key {
|
||||
case "tab", "shift+tab", "backtab":
|
||||
m.detail.ToggleZone()
|
||||
return nil, true
|
||||
case "ctrl+s":
|
||||
m.loading = true
|
||||
return m.saveDetailCmd(), true
|
||||
case "enter":
|
||||
if m.detail.Zone() == detailZoneActions {
|
||||
act, ok := m.detail.SelectedAction()
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
m.loading = true
|
||||
if act.ID == model.ActionSave {
|
||||
return m.saveDetailCmd(), true
|
||||
}
|
||||
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
||||
}
|
||||
}
|
||||
if m.detail.Zone() == detailZoneContent {
|
||||
return m.detail.Update(msg), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
|
||||
if m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(delta)
|
||||
return m, nil
|
||||
}
|
||||
if delta < 0 {
|
||||
m.detail.form.FocusPrev()
|
||||
} else {
|
||||
m.detail.form.FocusNext()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading…\n"
|
||||
}
|
||||
menuW, listW, prevW := LayoutWidths(m.width)
|
||||
h := m.height - 2
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight() + 2
|
||||
var parts []string
|
||||
if pw.Visibility.Menu {
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
||||
parts = append(parts, menuStyle.Render(m.menuVP.View()))
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
||||
parts = append(parts, listStyle.Render(m.listTable.View()))
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
||||
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||
}
|
||||
|
||||
status := m.status
|
||||
if m.loading {
|
||||
@@ -248,93 +389,98 @@ func (m Model) View() string {
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
menuStyle.Render(m.menuVP.View()),
|
||||
listStyle.Render(m.listVP.View()),
|
||||
prevStyle.Render(m.previewVP.View()),
|
||||
) + "\n" + bar
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...) + "\n" + bar
|
||||
}
|
||||
|
||||
func (m Model) paneVis() PaneVisibility {
|
||||
return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail}
|
||||
}
|
||||
|
||||
func (m Model) paneLayout() PaneWidths {
|
||||
return LayoutWidths(m.width, m.paneVis())
|
||||
}
|
||||
|
||||
func (m *Model) syncPaneFocus() {
|
||||
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||
}
|
||||
|
||||
func (m *Model) togglePane(which int) {
|
||||
switch which {
|
||||
case 1:
|
||||
m.showMenu = !m.showMenu
|
||||
case 2:
|
||||
m.showList = !m.showList
|
||||
case 3:
|
||||
m.showDetail = !m.showDetail
|
||||
}
|
||||
if !m.showMenu && !m.showList && !m.showDetail {
|
||||
switch which {
|
||||
case 1:
|
||||
m.showMenu = true
|
||||
case 2:
|
||||
m.showList = true
|
||||
case 3:
|
||||
m.showDetail = true
|
||||
}
|
||||
}
|
||||
if !paneVisible(m.focus, m.paneVis()) {
|
||||
m.focus = firstVisibleFocus(m.paneVis())
|
||||
}
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
m.syncPaneFocus()
|
||||
}
|
||||
|
||||
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
|
||||
m.listSpec = spec
|
||||
m.selection.Clear()
|
||||
m.listIdx = 0
|
||||
m.hasList = false
|
||||
m.actionMode = false
|
||||
m.focus = model.FocusList
|
||||
m.listTable.SetFocused(true)
|
||||
m.detail.Clear()
|
||||
m.loading = true
|
||||
return m, m.loadListCmd(spec)
|
||||
}
|
||||
|
||||
func (m *Model) openActionMenu() {
|
||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
||||
return
|
||||
}
|
||||
m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind)
|
||||
m.actionIdx = 0
|
||||
m.actionMode = true
|
||||
}
|
||||
|
||||
func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "esc":
|
||||
m.actionMode = false
|
||||
m.syncListContent()
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.actionIdx > 0 {
|
||||
m.actionIdx--
|
||||
}
|
||||
m.syncListContent()
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.actionIdx < len(m.itemActions)-1 {
|
||||
m.actionIdx++
|
||||
}
|
||||
m.syncListContent()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) {
|
||||
return m, nil
|
||||
}
|
||||
act := m.itemActions[m.actionIdx]
|
||||
item := m.items[m.listIdx]
|
||||
m.actionMode = false
|
||||
if act.ID == model.ActionView {
|
||||
return m, m.loadPreviewCmd()
|
||||
}
|
||||
m.loading = true
|
||||
return m, m.executeActionCmd(act.ID, item)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) layoutViewports() {
|
||||
menuW, listW, prevW := LayoutWidths(m.width)
|
||||
h := m.height - 4
|
||||
if h < 4 {
|
||||
h = 4
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight()
|
||||
if pw.Visibility.Menu {
|
||||
m.menuVP.Width = m.paneInnerWidth(pw.Menu)
|
||||
m.menuVP.Height = h
|
||||
}
|
||||
inner := func(w int) int {
|
||||
if w > 4 {
|
||||
return w - 2
|
||||
}
|
||||
return w
|
||||
if pw.Visibility.List {
|
||||
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
}
|
||||
m.menuVP = viewport.New(inner(menuW), h)
|
||||
m.listVP = viewport.New(inner(listW), h)
|
||||
m.previewVP = viewport.New(inner(prevW), h)
|
||||
}
|
||||
|
||||
func (m *Model) syncAllContent() {
|
||||
m.syncMenuContent()
|
||||
m.syncListContent()
|
||||
m.syncPreviewContent()
|
||||
m.syncListTable()
|
||||
}
|
||||
|
||||
func (m *Model) syncListTable() {
|
||||
if !m.hasList {
|
||||
m.listTable.Clear()
|
||||
return
|
||||
}
|
||||
m.listTable.UpdateItems(m.items)
|
||||
}
|
||||
|
||||
func (m *Model) syncMenuContent() {
|
||||
var b strings.Builder
|
||||
b.WriteString("Navigation\n\n")
|
||||
menuW := m.menuVP.Width
|
||||
if menuW < 10 {
|
||||
menuW = 20
|
||||
}
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("62")).
|
||||
Foreground(lipgloss.Color("255"))
|
||||
for i := 0; i < m.nav.VisibleCount(); i++ {
|
||||
n, _ := m.nav.NodeAtVisible(i)
|
||||
depth := m.nav.DepthAtVisible(i)
|
||||
@@ -350,9 +496,7 @@ func (m *Model) syncMenuContent() {
|
||||
line = prefix + "• " + n.Label
|
||||
}
|
||||
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
|
||||
line = "> " + line
|
||||
} else {
|
||||
line = " " + line
|
||||
line = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
@@ -360,78 +504,63 @@ func (m *Model) syncMenuContent() {
|
||||
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
|
||||
}
|
||||
|
||||
func (m *Model) syncListContent() {
|
||||
var b strings.Builder
|
||||
if !m.hasList {
|
||||
b.WriteString("List\n\n")
|
||||
b.WriteString("Select a leaf node in the tree\n")
|
||||
b.WriteString("(marked with •) and press Enter.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items))
|
||||
if m.actionMode {
|
||||
item := m.items[m.listIdx]
|
||||
fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title)
|
||||
for i, act := range m.itemActions {
|
||||
cursor := " "
|
||||
if i == m.actionIdx {
|
||||
cursor = "> "
|
||||
}
|
||||
label := act.Label
|
||||
if act.Danger {
|
||||
label = "⚠ " + label
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
||||
}
|
||||
b.WriteString("\nEnter: run · Esc: cancel\n")
|
||||
} else {
|
||||
for i, it := range m.items {
|
||||
mark := "[ ]"
|
||||
if it.Selected {
|
||||
mark = "[x]"
|
||||
}
|
||||
cursor := " "
|
||||
if i == m.listIdx && m.focus == model.FocusList {
|
||||
cursor = "> "
|
||||
}
|
||||
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
|
||||
if it.Subtitle != "" {
|
||||
line += " — " + it.Subtitle
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
if len(m.items) == 0 && !m.loading {
|
||||
b.WriteString("(empty)\n")
|
||||
}
|
||||
b.WriteString("\nEnter/a: actions · Space: select\n")
|
||||
}
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
m.listVP.SetContent(b.String())
|
||||
line := m.listIdx + 3
|
||||
if m.actionMode {
|
||||
line = m.actionIdx + 4
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
syncVPToLine(&m.listVP, line)
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func (m *Model) syncPreviewContent() {
|
||||
menuW, listW, prevW := LayoutWidths(m.width)
|
||||
_ = menuW
|
||||
_ = listW
|
||||
w := prevW - 4
|
||||
if w < 20 {
|
||||
w = 20
|
||||
func (m *Model) syncFocusedPane() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.syncMenuContent()
|
||||
case model.FocusList:
|
||||
m.syncListTable()
|
||||
}
|
||||
text, err := preview.RenderMarkdown(m.previewMD, w)
|
||||
if err != nil {
|
||||
text = m.previewMD
|
||||
}
|
||||
|
||||
func (m *Model) moveUp() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveUp()
|
||||
case model.FocusList:
|
||||
if m.hasList {
|
||||
m.listTable.MoveRow(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveDown() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveDown()
|
||||
case model.FocusList:
|
||||
if m.hasList {
|
||||
m.listTable.MoveRow(1)
|
||||
}
|
||||
}
|
||||
m.previewVP.SetContent(text)
|
||||
}
|
||||
|
||||
func syncVPToLine(vp *viewport.Model, line int) {
|
||||
if line < 0 {
|
||||
line = 0
|
||||
}
|
||||
max := vp.TotalLineCount()
|
||||
if max == 0 {
|
||||
vp.YOffset = 0
|
||||
return
|
||||
}
|
||||
if line >= max {
|
||||
line = max - 1
|
||||
}
|
||||
if line < vp.YOffset {
|
||||
vp.YOffset = line
|
||||
} else if line >= vp.YOffset+vp.Height {
|
||||
@@ -440,31 +569,12 @@ func syncVPToLine(vp *viewport.Model, line int) {
|
||||
if vp.YOffset < 0 {
|
||||
vp.YOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveUp() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveUp()
|
||||
case model.FocusList:
|
||||
if m.hasList && !m.actionMode && m.listIdx > 0 {
|
||||
m.listIdx--
|
||||
}
|
||||
case model.FocusPreview:
|
||||
m.previewVP.LineUp(1)
|
||||
maxOff := max - vp.Height
|
||||
if maxOff < 0 {
|
||||
maxOff = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveDown() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveDown()
|
||||
case model.FocusList:
|
||||
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
|
||||
m.listIdx++
|
||||
}
|
||||
case model.FocusPreview:
|
||||
m.previewVP.LineDown(1)
|
||||
if vp.YOffset > maxOff {
|
||||
vp.YOffset = maxOff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,22 +586,6 @@ func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadPreviewCmd() tea.Cmd {
|
||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
||||
return nil
|
||||
}
|
||||
item := m.items[m.listIdx]
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
raw, err := m.loader.Detail(ctx, item)
|
||||
if err != nil {
|
||||
return previewLoadedMsg{err: err}
|
||||
}
|
||||
md := preview.EntityMarkdown(string(item.Kind), raw)
|
||||
return previewLoadedMsg{text: md}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
@@ -508,6 +602,63 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onListRowChanged() tea.Cmd {
|
||||
if !m.hasList {
|
||||
m.detail.Clear()
|
||||
return nil
|
||||
}
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx < 0 || idx >= len(m.items) {
|
||||
m.detail.Clear()
|
||||
return nil
|
||||
}
|
||||
it := m.items[idx]
|
||||
if m.detail.LoadedID() == it.ID {
|
||||
return nil
|
||||
}
|
||||
m.loading = true
|
||||
return m.loadDetailCmd(it)
|
||||
}
|
||||
|
||||
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if model.IsDocumentKind(item.Kind) {
|
||||
md, err := m.loader.PreviewMarkdown(ctx, item)
|
||||
return detailLoadedMsg{item: item, document: true, markdown: md, err: err}
|
||||
}
|
||||
fields, err := m.loader.DetailForm(ctx, item)
|
||||
return detailLoadedMsg{item: item, fields: fields, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) saveDetailCmd() tea.Cmd {
|
||||
item := m.detail.Item()
|
||||
title := m.detail.form.Primary()
|
||||
desc := m.detail.form.Secondary()
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := m.loader.SaveItem(ctx, item, title, desc)
|
||||
return detailSavedMsg{item: item, title: title, description: desc, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
|
||||
for i := range m.items {
|
||||
if m.items[i].ID != item.ID {
|
||||
continue
|
||||
}
|
||||
m.items[i].Title = title
|
||||
if m.items[i].Raw == nil {
|
||||
m.items[i].Raw = map[string]any{}
|
||||
}
|
||||
m.items[i].Raw["title"] = title
|
||||
m.items[i].Raw["description"] = description
|
||||
m.listTable.UpdateItems(m.items)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func paneStyle(focused bool) lipgloss.Style {
|
||||
s := lipgloss.NewStyle().Padding(0, 1)
|
||||
if focused {
|
||||
@@ -517,5 +668,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
|
||||
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user