wip files

Kujtim Hoxha created

Change summary

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(-)

Detailed changes

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),
 	}

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,
 	}
 }

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
+}

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;

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,

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"`

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)
 }

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;

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,
+	}
+}
+

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):