From 8d874b839db169906e18e4277cd198504018e022 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 12 Apr 2025 02:01:45 +0200 Subject: add initial message handling --- internal/db/migrations/000001_initial.up.sql | 2 ++ 1 file changed, 2 insertions(+) (limited to 'internal/db/migrations') diff --git a/internal/db/migrations/000001_initial.up.sql b/internal/db/migrations/000001_initial.up.sql index 2fbe5547e..03479449d 100644 --- a/internal/db/migrations/000001_initial.up.sql +++ b/internal/db/migrations/000001_initial.up.sql @@ -24,8 +24,10 @@ CREATE TABLE IF NOT EXISTS messages ( session_id TEXT NOT NULL, role TEXT NOT NULL, parts TEXT NOT NULL default '[]', + model TEXT, created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds + finished_at INTEGER, -- Unix timestamp in milliseconds FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE ); -- cgit v1.2.3 From d63d0c4c44cb2356645fd112cec01598301f5949 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 13 Apr 2025 11:29:20 +0200 Subject: wip files --- internal/app/services.go | 4 + internal/db/db.go | 152 +++++++++--- internal/db/files.sql.go | 309 +++++++++++++++++++++++++ internal/db/migrations/000001_initial.down.sql | 2 + internal/db/migrations/000001_initial.up.sql | 22 ++ internal/db/models.go | 10 + internal/db/querier.go | 10 + internal/db/sql/files.sql | 69 ++++++ internal/history/file.go | 206 +++++++++++++++++ internal/tui/tui.go | 4 +- 10 files changed, 760 insertions(+), 28 deletions(-) create mode 100644 internal/db/files.sql.go create mode 100644 internal/db/sql/files.sql create mode 100644 internal/history/file.go (limited to 'internal/db/migrations') diff --git a/internal/app/services.go b/internal/app/services.go index 76b2226ae..6ecdef03c 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -6,6 +6,7 @@ import ( "github.com/kujtimiihoxha/termai/internal/config" "github.com/kujtimiihoxha/termai/internal/db" + "github.com/kujtimiihoxha/termai/internal/history" "github.com/kujtimiihoxha/termai/internal/logging" "github.com/kujtimiihoxha/termai/internal/lsp" "github.com/kujtimiihoxha/termai/internal/lsp/watcher" @@ -19,6 +20,7 @@ type App struct { Sessions session.Service Messages message.Service + Files history.Service Permissions permission.Service LSPClients map[string]*lsp.Client @@ -31,11 +33,13 @@ func New(ctx context.Context, conn *sql.DB) *App { q := db.New(conn) sessions := session.NewService(ctx, q) messages := message.NewService(ctx, q) + files := history.NewService(ctx, q) app := &App{ Context: ctx, Sessions: sessions, Messages: messages, + Files: files, Permissions: permission.NewPermissionService(), LSPClients: make(map[string]*lsp.Client), } diff --git a/internal/db/db.go b/internal/db/db.go index 75f626013..16e663804 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -24,33 +24,63 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil { + return nil, fmt.Errorf("error preparing query CreateFile: %w", err) + } if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil { return nil, fmt.Errorf("error preparing query CreateMessage: %w", err) } if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil { return nil, fmt.Errorf("error preparing query CreateSession: %w", err) } + if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil { + return nil, fmt.Errorf("error preparing query DeleteFile: %w", err) + } if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil { return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err) } if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil { return nil, fmt.Errorf("error preparing query DeleteSession: %w", err) } + if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil { + return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err) + } if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil { return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err) } + if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil { + return nil, fmt.Errorf("error preparing query GetFile: %w", err) + } + if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil { + return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err) + } if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil { return nil, fmt.Errorf("error preparing query GetMessage: %w", err) } if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil { return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err) } + if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil { + return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err) + } + if q.listFilesBySessionStmt, err = db.PrepareContext(ctx, listFilesBySession); err != nil { + return nil, fmt.Errorf("error preparing query ListFilesBySession: %w", err) + } + if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil { + return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err) + } if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil { return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err) } + if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil { + return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err) + } if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil { return nil, fmt.Errorf("error preparing query ListSessions: %w", err) } + if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil { + return nil, fmt.Errorf("error preparing query UpdateFile: %w", err) + } if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil { return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err) } @@ -62,6 +92,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createFileStmt != nil { + if cerr := q.createFileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createFileStmt: %w", cerr) + } + } if q.createMessageStmt != nil { if cerr := q.createMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createMessageStmt: %w", cerr) @@ -72,6 +107,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createSessionStmt: %w", cerr) } } + if q.deleteFileStmt != nil { + if cerr := q.deleteFileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteFileStmt: %w", cerr) + } + } if q.deleteMessageStmt != nil { if cerr := q.deleteMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr) @@ -82,11 +122,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr) } } + if q.deleteSessionFilesStmt != nil { + if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr) + } + } if q.deleteSessionMessagesStmt != nil { if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr) } } + if q.getFileStmt != nil { + if cerr := q.getFileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getFileStmt: %w", cerr) + } + } + if q.getFileByPathAndSessionStmt != nil { + if cerr := q.getFileByPathAndSessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr) + } + } if q.getMessageStmt != nil { if cerr := q.getMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getMessageStmt: %w", cerr) @@ -97,16 +152,41 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr) } } + if q.listFilesByPathStmt != nil { + if cerr := q.listFilesByPathStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr) + } + } + if q.listFilesBySessionStmt != nil { + if cerr := q.listFilesBySessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listFilesBySessionStmt: %w", cerr) + } + } + if q.listLatestSessionFilesStmt != nil { + if cerr := q.listLatestSessionFilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr) + } + } if q.listMessagesBySessionStmt != nil { if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr) } } + if q.listNewFilesStmt != nil { + if cerr := q.listNewFilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr) + } + } if q.listSessionsStmt != nil { if cerr := q.listSessionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listSessionsStmt: %w", cerr) } } + if q.updateFileStmt != nil { + if cerr := q.updateFileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateFileStmt: %w", cerr) + } + } if q.updateMessageStmt != nil { if cerr := q.updateMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateMessageStmt: %w", cerr) @@ -154,35 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createMessageStmt *sql.Stmt - createSessionStmt *sql.Stmt - deleteMessageStmt *sql.Stmt - deleteSessionStmt *sql.Stmt - deleteSessionMessagesStmt *sql.Stmt - getMessageStmt *sql.Stmt - getSessionByIDStmt *sql.Stmt - listMessagesBySessionStmt *sql.Stmt - listSessionsStmt *sql.Stmt - updateMessageStmt *sql.Stmt - updateSessionStmt *sql.Stmt + db DBTX + tx *sql.Tx + createFileStmt *sql.Stmt + createMessageStmt *sql.Stmt + createSessionStmt *sql.Stmt + deleteFileStmt *sql.Stmt + deleteMessageStmt *sql.Stmt + deleteSessionStmt *sql.Stmt + deleteSessionFilesStmt *sql.Stmt + deleteSessionMessagesStmt *sql.Stmt + getFileStmt *sql.Stmt + getFileByPathAndSessionStmt *sql.Stmt + getMessageStmt *sql.Stmt + getSessionByIDStmt *sql.Stmt + listFilesByPathStmt *sql.Stmt + listFilesBySessionStmt *sql.Stmt + listLatestSessionFilesStmt *sql.Stmt + listMessagesBySessionStmt *sql.Stmt + listNewFilesStmt *sql.Stmt + listSessionsStmt *sql.Stmt + updateFileStmt *sql.Stmt + updateMessageStmt *sql.Stmt + updateSessionStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createMessageStmt: q.createMessageStmt, - createSessionStmt: q.createSessionStmt, - deleteMessageStmt: q.deleteMessageStmt, - deleteSessionStmt: q.deleteSessionStmt, - deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, - getMessageStmt: q.getMessageStmt, - getSessionByIDStmt: q.getSessionByIDStmt, - listMessagesBySessionStmt: q.listMessagesBySessionStmt, - listSessionsStmt: q.listSessionsStmt, - updateMessageStmt: q.updateMessageStmt, - updateSessionStmt: q.updateSessionStmt, + db: tx, + tx: tx, + createFileStmt: q.createFileStmt, + createMessageStmt: q.createMessageStmt, + createSessionStmt: q.createSessionStmt, + deleteFileStmt: q.deleteFileStmt, + deleteMessageStmt: q.deleteMessageStmt, + deleteSessionStmt: q.deleteSessionStmt, + deleteSessionFilesStmt: q.deleteSessionFilesStmt, + deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, + getFileStmt: q.getFileStmt, + getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getMessageStmt: q.getMessageStmt, + getSessionByIDStmt: q.getSessionByIDStmt, + listFilesByPathStmt: q.listFilesByPathStmt, + listFilesBySessionStmt: q.listFilesBySessionStmt, + listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, + listMessagesBySessionStmt: q.listMessagesBySessionStmt, + listNewFilesStmt: q.listNewFilesStmt, + listSessionsStmt: q.listSessionsStmt, + updateFileStmt: q.updateFileStmt, + updateMessageStmt: q.updateMessageStmt, + updateSessionStmt: q.updateSessionStmt, } } diff --git a/internal/db/files.sql.go b/internal/db/files.sql.go new file mode 100644 index 000000000..b45731098 --- /dev/null +++ b/internal/db/files.sql.go @@ -0,0 +1,309 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: files.sql + +package db + +import ( + "context" +) + +const createFile = `-- name: CreateFile :one +INSERT INTO files ( + id, + session_id, + path, + content, + version, + created_at, + updated_at +) VALUES ( + ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now') +) +RETURNING id, session_id, path, content, version, created_at, updated_at +` + +type CreateFileParams struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Path string `json:"path"` + Content string `json:"content"` + Version string `json:"version"` +} + +func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) { + row := q.queryRow(ctx, q.createFileStmt, createFile, + arg.ID, + arg.SessionID, + arg.Path, + arg.Content, + arg.Version, + ) + var i File + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteFile = `-- name: DeleteFile :exec +DELETE FROM files +WHERE id = ? +` + +func (q *Queries) DeleteFile(ctx context.Context, id string) error { + _, err := q.exec(ctx, q.deleteFileStmt, deleteFile, id) + return err +} + +const deleteSessionFiles = `-- name: DeleteSessionFiles :exec +DELETE FROM files +WHERE session_id = ? +` + +func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) error { + _, err := q.exec(ctx, q.deleteSessionFilesStmt, deleteSessionFiles, sessionID) + return err +} + +const getFile = `-- name: GetFile :one +SELECT id, session_id, path, content, version, created_at, updated_at +FROM files +WHERE id = ? LIMIT 1 +` + +func (q *Queries) GetFile(ctx context.Context, id string) (File, error) { + row := q.queryRow(ctx, q.getFileStmt, getFile, id) + var i File + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one +SELECT id, session_id, path, content, version, created_at, updated_at +FROM files +WHERE path = ? AND session_id = ? LIMIT 1 +` + +type GetFileByPathAndSessionParams struct { + Path string `json:"path"` + SessionID string `json:"session_id"` +} + +func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) { + row := q.queryRow(ctx, q.getFileByPathAndSessionStmt, getFileByPathAndSession, arg.Path, arg.SessionID) + var i File + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listFilesByPath = `-- name: ListFilesByPath :many +SELECT id, session_id, path, content, version, created_at, updated_at +FROM files +WHERE path = ? +ORDER BY created_at DESC +` + +func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) { + rows, err := q.query(ctx, q.listFilesByPathStmt, listFilesByPath, path) + if err != nil { + return nil, err + } + defer rows.Close() + items := []File{} + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFilesBySession = `-- name: ListFilesBySession :many +SELECT id, session_id, path, content, version, created_at, updated_at +FROM files +WHERE session_id = ? +ORDER BY created_at ASC +` + +func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) { + rows, err := q.query(ctx, q.listFilesBySessionStmt, listFilesBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []File{} + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many +SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at +FROM files f +INNER JOIN ( + SELECT path, MAX(created_at) as max_created_at + FROM files + GROUP BY path +) latest ON f.path = latest.path AND f.created_at = latest.max_created_at +WHERE f.session_id = ? +ORDER BY f.path +` + +func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) { + rows, err := q.query(ctx, q.listLatestSessionFilesStmt, listLatestSessionFiles, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []File{} + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNewFiles = `-- name: ListNewFiles :many +SELECT id, session_id, path, content, version, created_at, updated_at +FROM files +WHERE is_new = 1 +ORDER BY created_at DESC +` + +func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) { + rows, err := q.query(ctx, q.listNewFilesStmt, listNewFiles) + if err != nil { + return nil, err + } + defer rows.Close() + items := []File{} + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateFile = `-- name: UpdateFile :one +UPDATE files +SET + content = ?, + version = ?, + updated_at = strftime('%s', 'now') +WHERE id = ? +RETURNING id, session_id, path, content, version, created_at, updated_at +` + +type UpdateFileParams struct { + Content string `json:"content"` + Version string `json:"version"` + ID string `json:"id"` +} + +func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) { + row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID) + var i File + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Path, + &i.Content, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/migrations/000001_initial.down.sql b/internal/db/migrations/000001_initial.down.sql index 4f2712d86..a2b0d1321 100644 --- a/internal/db/migrations/000001_initial.down.sql +++ b/internal/db/migrations/000001_initial.down.sql @@ -1,8 +1,10 @@ DROP TRIGGER IF EXISTS update_sessions_updated_at; DROP TRIGGER IF EXISTS update_messages_updated_at; +DROP TRIGGER IF EXISTS update_files_updated_at; DROP TRIGGER IF EXISTS update_session_message_count_on_delete; DROP TRIGGER IF EXISTS update_session_message_count_on_insert; DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS files; diff --git a/internal/db/migrations/000001_initial.up.sql b/internal/db/migrations/000001_initial.up.sql index 03479449d..4ac297dc5 100644 --- a/internal/db/migrations/000001_initial.up.sql +++ b/internal/db/migrations/000001_initial.up.sql @@ -18,6 +18,28 @@ UPDATE sessions SET updated_at = strftime('%s', 'now') WHERE id = new.id; END; +-- Files +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + version TEXT NOT NULL, + created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds + updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id); +CREATE INDEX IF NOT EXISTS idx_files_path ON files (path); + +CREATE TRIGGER IF NOT EXISTS update_files_updated_at +AFTER UPDATE ON files +BEGIN +UPDATE files SET updated_at = strftime('%s', 'now') +WHERE id = new.id; +END; + -- Messages CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, diff --git a/internal/db/models.go b/internal/db/models.go index 2fad913be..f00cb6ad1 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -8,6 +8,16 @@ import ( "database/sql" ) +type File struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Path string `json:"path"` + Content string `json:"content"` + Version string `json:"version"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type Message struct { ID string `json:"id"` SessionID string `json:"session_id"` diff --git a/internal/db/querier.go b/internal/db/querier.go index c9d73ec39..704a97da2 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -9,15 +9,25 @@ import ( ) type Querier interface { + CreateFile(ctx context.Context, arg CreateFileParams) (File, error) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) + DeleteFile(ctx context.Context, id string) error DeleteMessage(ctx context.Context, id string) error DeleteSession(ctx context.Context, id string) error + DeleteSessionFiles(ctx context.Context, sessionID string) error DeleteSessionMessages(ctx context.Context, sessionID string) error + GetFile(ctx context.Context, id string) (File, error) + GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) GetMessage(ctx context.Context, id string) (Message, error) GetSessionByID(ctx context.Context, id string) (Session, error) + ListFilesByPath(ctx context.Context, path string) ([]File, error) + ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) + ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) + ListNewFiles(ctx context.Context) ([]File, error) ListSessions(ctx context.Context) ([]Session, error) + UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) } diff --git a/internal/db/sql/files.sql b/internal/db/sql/files.sql new file mode 100644 index 000000000..c2e799076 --- /dev/null +++ b/internal/db/sql/files.sql @@ -0,0 +1,69 @@ +-- name: GetFile :one +SELECT * +FROM files +WHERE id = ? LIMIT 1; + +-- name: GetFileByPathAndSession :one +SELECT * +FROM files +WHERE path = ? AND session_id = ? LIMIT 1; + +-- name: ListFilesBySession :many +SELECT * +FROM files +WHERE session_id = ? +ORDER BY created_at ASC; + +-- name: ListFilesByPath :many +SELECT * +FROM files +WHERE path = ? +ORDER BY created_at DESC; + +-- name: CreateFile :one +INSERT INTO files ( + id, + session_id, + path, + content, + version, + created_at, + updated_at +) VALUES ( + ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now') +) +RETURNING *; + +-- name: UpdateFile :one +UPDATE files +SET + content = ?, + version = ?, + updated_at = strftime('%s', 'now') +WHERE id = ? +RETURNING *; + +-- name: DeleteFile :exec +DELETE FROM files +WHERE id = ?; + +-- name: DeleteSessionFiles :exec +DELETE FROM files +WHERE session_id = ?; + +-- name: ListLatestSessionFiles :many +SELECT f.* +FROM files f +INNER JOIN ( + SELECT path, MAX(created_at) as max_created_at + FROM files + GROUP BY path +) latest ON f.path = latest.path AND f.created_at = latest.max_created_at +WHERE f.session_id = ? +ORDER BY f.path; + +-- name: ListNewFiles :many +SELECT * +FROM files +WHERE is_new = 1 +ORDER BY created_at DESC; diff --git a/internal/history/file.go b/internal/history/file.go new file mode 100644 index 000000000..25953b273 --- /dev/null +++ b/internal/history/file.go @@ -0,0 +1,206 @@ +package history + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/kujtimiihoxha/termai/internal/db" + "github.com/kujtimiihoxha/termai/internal/pubsub" +) + +const ( + InitialVersion = "initial" +) + +type File struct { + ID string + SessionID string + Path string + Content string + Version string + CreatedAt int64 + UpdatedAt int64 +} + +type Service interface { + pubsub.Suscriber[File] + Create(sessionID, path, content string) (File, error) + CreateVersion(sessionID, path, content string) (File, error) + Get(id string) (File, error) + GetByPathAndSession(path, sessionID string) (File, error) + ListBySession(sessionID string) ([]File, error) + ListLatestSessionFiles(sessionID string) ([]File, error) + Update(file File) (File, error) + Delete(id string) error + DeleteSessionFiles(sessionID string) error +} + +type service struct { + *pubsub.Broker[File] + q db.Querier + ctx context.Context +} + +func NewService(ctx context.Context, q db.Querier) Service { + return &service{ + Broker: pubsub.NewBroker[File](), + q: q, + ctx: ctx, + } +} + +func (s *service) Create(sessionID, path, content string) (File, error) { + return s.createWithVersion(sessionID, path, content, InitialVersion) +} + +func (s *service) CreateVersion(sessionID, path, content string) (File, error) { + // Get the latest version for this path + files, err := s.q.ListFilesByPath(s.ctx, path) + if err != nil { + return File{}, err + } + + if len(files) == 0 { + // No previous versions, create initial + return s.Create(sessionID, path, content) + } + + // Get the latest version + latestFile := files[0] // Files are ordered by created_at DESC + latestVersion := latestFile.Version + + // Generate the next version + var nextVersion string + if latestVersion == InitialVersion { + nextVersion = "v1" + } else if strings.HasPrefix(latestVersion, "v") { + versionNum, err := strconv.Atoi(latestVersion[1:]) + if err != nil { + // If we can't parse the version, just use a timestamp-based version + nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt) + } else { + nextVersion = fmt.Sprintf("v%d", versionNum+1) + } + } else { + // If the version format is unexpected, use a timestamp-based version + nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt) + } + + return s.createWithVersion(sessionID, path, content, nextVersion) +} + +func (s *service) createWithVersion(sessionID, path, content, version string) (File, error) { + dbFile, err := s.q.CreateFile(s.ctx, db.CreateFileParams{ + ID: uuid.New().String(), + SessionID: sessionID, + Path: path, + Content: content, + Version: version, + }) + if err != nil { + return File{}, err + } + file := s.fromDBItem(dbFile) + s.Publish(pubsub.CreatedEvent, file) + return file, nil +} + +func (s *service) Get(id string) (File, error) { + dbFile, err := s.q.GetFile(s.ctx, id) + if err != nil { + return File{}, err + } + return s.fromDBItem(dbFile), nil +} + +func (s *service) GetByPathAndSession(path, sessionID string) (File, error) { + dbFile, err := s.q.GetFileByPathAndSession(s.ctx, db.GetFileByPathAndSessionParams{ + Path: path, + SessionID: sessionID, + }) + if err != nil { + return File{}, err + } + return s.fromDBItem(dbFile), nil +} + +func (s *service) ListBySession(sessionID string) ([]File, error) { + dbFiles, err := s.q.ListFilesBySession(s.ctx, sessionID) + if err != nil { + return nil, err + } + files := make([]File, len(dbFiles)) + for i, dbFile := range dbFiles { + files[i] = s.fromDBItem(dbFile) + } + return files, nil +} + +func (s *service) ListLatestSessionFiles(sessionID string) ([]File, error) { + dbFiles, err := s.q.ListLatestSessionFiles(s.ctx, sessionID) + if err != nil { + return nil, err + } + files := make([]File, len(dbFiles)) + for i, dbFile := range dbFiles { + files[i] = s.fromDBItem(dbFile) + } + return files, nil +} + +func (s *service) Update(file File) (File, error) { + dbFile, err := s.q.UpdateFile(s.ctx, db.UpdateFileParams{ + ID: file.ID, + Content: file.Content, + Version: file.Version, + }) + if err != nil { + return File{}, err + } + updatedFile := s.fromDBItem(dbFile) + s.Publish(pubsub.UpdatedEvent, updatedFile) + return updatedFile, nil +} + +func (s *service) Delete(id string) error { + file, err := s.Get(id) + if err != nil { + return err + } + err = s.q.DeleteFile(s.ctx, id) + if err != nil { + return err + } + s.Publish(pubsub.DeletedEvent, file) + return nil +} + +func (s *service) DeleteSessionFiles(sessionID string) error { + files, err := s.ListBySession(sessionID) + if err != nil { + return err + } + for _, file := range files { + err = s.Delete(file.ID) + if err != nil { + return err + } + } + return nil +} + +func (s *service) fromDBItem(item db.File) File { + return File{ + ID: item.ID, + SessionID: item.SessionID, + Path: item.Path, + Content: item.Content, + Version: item.Version, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eb996d448..db9ac9ff6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -198,8 +198,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID}) } - case key.Matches(msg, keys.Logs): - return a, a.moveToPage(page.LogsPage) + // case key.Matches(msg, keys.Logs): + // return a, a.moveToPage(page.LogsPage) case msg.String() == "O": return a, a.moveToPage(page.ReplPage) case key.Matches(msg, keys.Help): -- cgit v1.2.3 From c24e3c18e0177e2e059f622a63e87249c52db2d5 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 17 Apr 2025 13:45:26 +0200 Subject: small improvements --- .opencode.json | 11 ---- internal/app/app.go | 2 +- internal/db/migrations/000001_initial.up.sql | 3 +- internal/history/file.go | 76 +++++++++++++++++++++++----- internal/tui/components/chat/editor.go | 4 +- internal/tui/components/chat/sidebar.go | 5 +- internal/tui/components/core/status.go | 56 +++++++++++++++++++- internal/tui/components/dialog/help.go | 4 +- internal/tui/layout/container.go | 1 + internal/tui/page/chat.go | 22 ++++++-- internal/tui/tui.go | 2 + 11 files changed, 149 insertions(+), 37 deletions(-) (limited to 'internal/db/migrations') diff --git a/.opencode.json b/.opencode.json index 4b2944f86..b7fc19b52 100644 --- a/.opencode.json +++ b/.opencode.json @@ -3,16 +3,5 @@ "gopls": { "command": "gopls" } - }, - "agents": { - "coder": { - "model": "gpt-4.1" - }, - "task": { - "model": "gpt-4.1" - }, - "title": { - "model": "gpt-4.1" - } } } diff --git a/internal/app/app.go b/internal/app/app.go index 748fdaa7f..8f4f5e098 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -39,7 +39,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { q := db.New(conn) sessions := session.NewService(q) messages := message.NewService(q) - files := history.NewService(q) + files := history.NewService(q, conn) app := &App{ Sessions: sessions, diff --git a/internal/db/migrations/000001_initial.up.sql b/internal/db/migrations/000001_initial.up.sql index 4ac297dc5..b846ec600 100644 --- a/internal/db/migrations/000001_initial.up.sql +++ b/internal/db/migrations/000001_initial.up.sql @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS files ( version TEXT NOT NULL, created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds - FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE, + UNIQUE(path, session_id, version) ); CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id); diff --git a/internal/history/file.go b/internal/history/file.go index 1e8bc50bb..8453ac272 100644 --- a/internal/history/file.go +++ b/internal/history/file.go @@ -2,9 +2,11 @@ package history import ( "context" + "database/sql" "fmt" "strconv" "strings" + "time" "github.com/google/uuid" "github.com/kujtimiihoxha/opencode/internal/db" @@ -40,10 +42,11 @@ type Service interface { type service struct { *pubsub.Broker[File] - q db.Querier + db *sql.DB + q *db.Queries } -func NewService(q db.Querier) Service { +func NewService(q *db.Queries, db *sql.DB) Service { return &service{ Broker: pubsub.NewBroker[File](), q: q, @@ -91,19 +94,64 @@ func (s *service) CreateVersion(ctx context.Context, sessionID, path, content st } func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) { - dbFile, err := s.q.CreateFile(ctx, db.CreateFileParams{ - ID: uuid.New().String(), - SessionID: sessionID, - Path: path, - Content: content, - Version: version, - }) - if err != nil { - return File{}, err + // Maximum number of retries for transaction conflicts + const maxRetries = 3 + var file File + var err error + + // Retry loop for transaction conflicts + for attempt := 0; attempt < maxRetries; attempt++ { + // Start a transaction + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return File{}, fmt.Errorf("failed to begin transaction: %w", err) + } + + // Create a new queries instance with the transaction + qtx := s.q.WithTx(tx) + + // Try to create the file within the transaction + dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{ + ID: uuid.New().String(), + SessionID: sessionID, + Path: path, + Content: content, + Version: version, + }) + if err != nil { + // Rollback the transaction + tx.Rollback() + + // Check if this is a uniqueness constraint violation + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if attempt < maxRetries-1 { + // If we have retries left, generate a new version and try again + if strings.HasPrefix(version, "v") { + versionNum, parseErr := strconv.Atoi(version[1:]) + if parseErr == nil { + version = fmt.Sprintf("v%d", versionNum+1) + continue + } + } + // If we can't parse the version, use a timestamp-based version + version = fmt.Sprintf("v%d", time.Now().Unix()) + continue + } + } + return File{}, err + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + return File{}, fmt.Errorf("failed to commit transaction: %w", err) + } + + file = s.fromDBItem(dbFile) + s.Publish(pubsub.CreatedEvent, file) + return file, nil } - file := s.fromDBItem(dbFile) - s.Publish(pubsub.CreatedEvent, file) - return file, nil + + return file, err } func (s *service) Get(ctx context.Context, id string) (File, error) { diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 4d6ef5ca0..ded0639bb 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -118,12 +118,14 @@ func (m *editorCmp) GetSize() (int, int) { } func (m *editorCmp) BindingKeys() []key.Binding { - bindings := layout.KeyMapToSlice(m.textarea.KeyMap) + bindings := []key.Binding{} if m.textarea.Focused() { bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...) } else { bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...) } + + bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...) return bindings } diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index fe2845a08..5a275c0cf 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -127,7 +127,7 @@ func (m *sidebarCmp) modifiedFiles() string { // If no modified files, show a placeholder message if m.modFiles == nil || len(m.modFiles) == 0 { message := "No modified files" - remainingWidth := m.width - lipgloss.Width(modifiedFiles) + remainingWidth := m.width - lipgloss.Width(message) if remainingWidth > 0 { message += strings.Repeat(" ", remainingWidth) } @@ -223,6 +223,9 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { if initialVersion.ID == "" { continue } + if initialVersion.Content == file.Content { + continue + } // Calculate diff between initial and latest version _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 411cac1c5..e76ecde84 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -11,6 +11,9 @@ import ( "github.com/kujtimiihoxha/opencode/internal/llm/models" "github.com/kujtimiihoxha/opencode/internal/lsp" "github.com/kujtimiihoxha/opencode/internal/lsp/protocol" + "github.com/kujtimiihoxha/opencode/internal/pubsub" + "github.com/kujtimiihoxha/opencode/internal/session" + "github.com/kujtimiihoxha/opencode/internal/tui/components/chat" "github.com/kujtimiihoxha/opencode/internal/tui/styles" "github.com/kujtimiihoxha/opencode/internal/tui/util" ) @@ -20,6 +23,7 @@ type statusCmp struct { width int messageTTL time.Duration lspClients map[string]*lsp.Client + session session.Session } // clearMessageCmd is a command that clears status messages after a timeout @@ -38,6 +42,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil + case chat.SessionSelectedMsg: + m.session = msg + case chat.SessionClearedMsg: + m.session = session.Session{} + case pubsub.Event[session.Session]: + if msg.Type == pubsub.UpdatedEvent { + if m.session.ID == msg.Payload.ID { + m.session = msg.Payload + } + } case util.InfoMsg: m.info = msg ttl := msg.TTL @@ -53,8 +67,43 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help") +func formatTokensAndCost(tokens int64, cost float64) string { + // Format tokens in human-readable format (e.g., 110K, 1.2M) + var formattedTokens string + switch { + case tokens >= 1_000_000: + formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) + default: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + // Remove .0 suffix if present + if strings.HasSuffix(formattedTokens, ".0K") { + formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) + } + if strings.HasSuffix(formattedTokens, ".0M") { + formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) + } + + // Format cost with $ symbol and 2 decimal places + formattedCost := fmt.Sprintf("$%.2f", cost) + + return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost) +} + func (m statusCmp) View() string { status := helpWidget + if m.session.ID != "" { + tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost) + tokensStyle := styles.Padded. + Background(styles.Forground). + Foreground(styles.BackgroundDim). + Render(tokens) + status += tokensStyle + } + diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics()) if m.info.Msg != "" { infoStyle := styles.Padded. @@ -82,6 +131,7 @@ func (m statusCmp) View() string { Width(m.availableFooterMsgWidth(diagnostics)). Render("") } + status += diagnostics status += m.model() return status @@ -136,7 +186,11 @@ func (m *statusCmp) projectDiagnostics() string { } func (m statusCmp) availableFooterMsgWidth(diagnostics string) int { - return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)) + tokens := "" + if m.session.ID != "" { + tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost) + } + return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens)) } func (m statusCmp) model() string { diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go index 6242017f1..644b294cb 100644 --- a/internal/tui/components/dialog/help.go +++ b/internal/tui/components/dialog/help.go @@ -26,7 +26,7 @@ func (h *helpCmp) SetBindings(k []key.Binding) { func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - h.width = 80 + h.width = 90 h.height = msg.Height } return h, nil @@ -62,7 +62,7 @@ func (h *helpCmp) render() string { var ( pairs []string width int - rows = 12 - 2 + rows = 14 - 2 ) for i := 0; i < len(bindings); i += rows { var ( diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go index 603699955..c86d954ea 100644 --- a/internal/tui/layout/container.go +++ b/internal/tui/layout/container.go @@ -10,6 +10,7 @@ import ( type Container interface { tea.Model Sizeable + Bindings } type container struct { width int diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index c268e677f..632e10764 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -15,9 +15,12 @@ import ( var ChatPage PageID = "chat" type chatPage struct { - app *app.App - layout layout.SplitPaneLayout - session session.Session + app *app.App + editor layout.Container + messages layout.Container + layout layout.SplitPaneLayout + session session.Session + editingMode bool } type ChatKeyMap struct { @@ -59,6 +62,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case chat.EditorFocusMsg: + p.editingMode = bool(msg) case tea.KeyMsg: switch { case key.Matches(msg, keyMap.NewSession): @@ -133,7 +138,11 @@ func (p *chatPage) View() string { func (p *chatPage) BindingKeys() []key.Binding { bindings := layout.KeyMapToSlice(keyMap) - bindings = append(bindings, p.layout.BindingKeys()...) + if p.editingMode { + bindings = append(bindings, p.editor.BindingKeys()...) + } else { + bindings = append(bindings, p.messages.BindingKeys()...) + } return bindings } @@ -148,7 +157,10 @@ func NewChatPage(app *app.App) tea.Model { layout.WithBorder(true, false, false, false), ) return &chatPage{ - app: app, + app: app, + editor: editorContainer, + messages: messagesContainer, + editingMode: true, layout: layout.NewSplitPane( layout.WithLeftPanel(messagesContainer), layout.WithBottomPanel(editorContainer), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 657de6b6e..840ad4905 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -215,6 +215,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } } + + a.status, _ = a.status.Update(msg) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) -- cgit v1.2.3