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:
@@ -0,0 +1,287 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TableColumn is one column in the center data table.
|
||||
type TableColumn struct {
|
||||
Key string
|
||||
Title string
|
||||
Width int
|
||||
}
|
||||
|
||||
var subjectExtraKeys = map[Subject][]string{
|
||||
SubjectTasks: {"status", "deadline", "responsible"},
|
||||
SubjectCalendars: {"description"},
|
||||
SubjectEvents: {"start", "end"},
|
||||
SubjectContacts: {"primaryEmail", "displayName"},
|
||||
SubjectPersons: {"primaryEmail", "displayName"},
|
||||
SubjectCompanies: {"primaryEmail", "displayName"},
|
||||
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
|
||||
SubjectCases: {"status"},
|
||||
SubjectCRMTasks: {"status", "deadline"},
|
||||
SubjectMailInbox: {"from", "date"},
|
||||
SubjectMailSent: {"to", "date"},
|
||||
SubjectMailDrafts: {"to", "date"},
|
||||
SubjectMailTrash: {"from", "date"},
|
||||
SubjectMailSpam: {"from", "date"},
|
||||
SubjectUsers: {"email", "displayName"},
|
||||
}
|
||||
|
||||
// BuildColumns derives table columns from the list subject and item payloads.
|
||||
func BuildColumns(subject Subject, items []Item) []TableColumn {
|
||||
if subject == SubjectProjects {
|
||||
return buildProjectColumns(items)
|
||||
}
|
||||
cols := []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "id", Title: "ID", Width: 10},
|
||||
{Key: "title", Title: "Title", Width: 28},
|
||||
}
|
||||
if hasSubtitle(items) {
|
||||
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
|
||||
}
|
||||
|
||||
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
|
||||
for _, key := range subjectExtraKeys[subject] {
|
||||
if seen[key] || !columnHasData(items, key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||
}
|
||||
|
||||
discovered := discoverRawKeys(items, seen, 8)
|
||||
for _, key := range discovered {
|
||||
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||
}
|
||||
|
||||
sizeColumns(cols, items)
|
||||
return cols
|
||||
}
|
||||
|
||||
func buildProjectColumns(items []Item) []TableColumn {
|
||||
cols := []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "id", Title: "ID", Width: 8},
|
||||
{Key: "title", Title: "Title", Width: 28},
|
||||
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
|
||||
{Key: "documents", Title: "Documents", Width: 11},
|
||||
{Key: "users", Title: "Users", Width: 8},
|
||||
}
|
||||
sizeColumns(cols, items)
|
||||
return cols
|
||||
}
|
||||
|
||||
// CellText returns the display string for one table cell.
|
||||
func CellText(it Item, key string) string {
|
||||
switch key {
|
||||
case "_sel":
|
||||
if it.Selected {
|
||||
return "●"
|
||||
}
|
||||
return "○"
|
||||
case "id":
|
||||
return it.ID
|
||||
case "title":
|
||||
return it.Title
|
||||
case "subtitle":
|
||||
return it.Subtitle
|
||||
case "tasks":
|
||||
return formatProjectTasks(it.Raw)
|
||||
case "documents":
|
||||
return intRaw(it.Raw, "documentsCount")
|
||||
case "users":
|
||||
return intRaw(it.Raw, "participantCount")
|
||||
case "kind":
|
||||
return string(it.Kind)
|
||||
default:
|
||||
if it.Raw == nil {
|
||||
return ""
|
||||
}
|
||||
return formatAny(it.Raw[key])
|
||||
}
|
||||
}
|
||||
|
||||
func formatProjectTasks(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return "0/0"
|
||||
}
|
||||
open := intRawVal(raw, "taskCount")
|
||||
total := intRawVal(raw, "taskCountTotal")
|
||||
closed := total - open
|
||||
if closed < 0 {
|
||||
closed = 0
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", open, closed)
|
||||
}
|
||||
|
||||
func intRaw(raw map[string]any, key string) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
v := intRawVal(raw, key)
|
||||
if v == 0 {
|
||||
if _, ok := raw[key]; !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d", v)
|
||||
}
|
||||
|
||||
func intRawVal(raw map[string]any, key string) int {
|
||||
if raw == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := raw[key].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n
|
||||
default:
|
||||
if raw[key] == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
func hasSubtitle(items []Item) bool {
|
||||
for _, it := range items {
|
||||
if it.Subtitle != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func columnHasData(items []Item, key string) bool {
|
||||
for _, it := range items {
|
||||
if CellText(it, key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
if it.Raw == nil {
|
||||
continue
|
||||
}
|
||||
for k := range it.Raw {
|
||||
if seen[k] || k == "id" || k == "title" {
|
||||
continue
|
||||
}
|
||||
counts[k]++
|
||||
}
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
n int
|
||||
}
|
||||
var ranked []kv
|
||||
for k, n := range counts {
|
||||
ranked = append(ranked, kv{k, n})
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].n == ranked[j].n {
|
||||
return ranked[i].k < ranked[j].k
|
||||
}
|
||||
return ranked[i].n > ranked[j].n
|
||||
})
|
||||
out := make([]string, 0, limit)
|
||||
for _, r := range ranked {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, r.k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func titleLabel(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(key, "_")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func defaultWidth(key string) int {
|
||||
switch key {
|
||||
case "id", "status", "stage":
|
||||
return 10
|
||||
case "start", "end", "deadline", "date":
|
||||
return 18
|
||||
case "from", "to", "email", "primaryEmail":
|
||||
return 22
|
||||
case "description", "displayName":
|
||||
return 24
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
|
||||
func sizeColumns(cols []TableColumn, items []Item) {
|
||||
for i := range cols {
|
||||
maxW := runeLen(cols[i].Title)
|
||||
for _, it := range items {
|
||||
n := runeLen(CellText(it, cols[i].Key))
|
||||
if n > maxW {
|
||||
maxW = n
|
||||
}
|
||||
}
|
||||
maxW += 2
|
||||
if maxW < 4 {
|
||||
maxW = 4
|
||||
}
|
||||
if maxW > 36 {
|
||||
maxW = 36
|
||||
}
|
||||
cols[i].Width = maxW
|
||||
}
|
||||
}
|
||||
|
||||
func runeLen(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
func formatAny(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return fmt.Sprintf("%g", x)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user