Skip to content

Commit 5acb564

Browse files
feat: add mobile bottom tab navigation and OpenCode authentication
Major Features: - Add mobile bottom tab navigation with swipe gestures - Implement MobileTabBar component with navigation store - Add RepoQuickSwitchSheet and NotificationsSheet components - Add OpenCode server authentication with Basic Auth support - Add OpenCode models editor UI Improvements: - Add side-drawer and bottom-sheet UI components - Implement useMobileTabBar and useNavigationDirection hooks - Add useAutoPlayLastResponse hook for TTS - Improve OAuth dialog UX with copy buttons and toast notifications - Add floating TTS button with better state management - Add model fallback logic and validation Fixes: - Correctly handle modelIDs containing slashes - Fix OAuth dialog state management - Clean up stale models from recentModels - Fix SQLite readonly issues in Docker - Fix mobile repo controls event bubbling Refactoring: - Remove custom title generation, use OpenCode API - Merge RepoSelectionBar into RepoListControls - Replace radio buttons with Tabs in AddRepoDialog - Remove title.ts route and tests Infrastructure: - Add cache and opencode directories to Docker setup - Fix Docker volume permissions for SQLite database - Add migration for repo last accessed timestamp Testing: - Add comprehensive tests for mobile navigation components - Add tests for bottom-sheet, side-drawer, and MobileTabBar - Add model fallback tests - Add proxy service tests
1 parent 323ea56 commit 5acb564

124 files changed

Lines changed: 11397 additions & 1624 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ LOG_LEVEL=info
1717
OPENCODE_SERVER_PORT=5551
1818
OPENCODE_HOST=127.0.0.1
1919

20+
# Optional - bearer password required to talk to the spawned OpenCode server.
21+
# When set, the backend spawns OpenCode with this password and attaches it to
22+
# every proxied request. Leave unset to disable OpenCode-level auth.
23+
# OPENCODE_SERVER_PASSWORD=
24+
2025
# Optional - import an existing standalone OpenCode install on first startup
2126
# Useful for Docker when your host OpenCode data is bind-mounted into the container
2227
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json

backend/src/auth/middleware.ts

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -40,62 +40,4 @@ export function createAuthMiddleware(auth: AuthInstance) {
4040
})
4141
}
4242

43-
export function createOptionalAuthMiddleware(auth: AuthInstance) {
44-
return createMiddleware<{
45-
Variables: {
46-
session: Session['session'] | null
47-
user: Session['user'] | null
48-
}
49-
}>(async (c, next) => {
50-
try {
51-
const session = await auth.api.getSession({
52-
headers: c.req.raw.headers,
53-
})
54-
55-
if (session) {
56-
c.set('session', session.session as Session['session'])
57-
c.set('user', session.user as Session['user'])
58-
} else {
59-
c.set('session', null)
60-
c.set('user', null)
61-
}
62-
} catch {
63-
c.set('session', null)
64-
c.set('user', null)
65-
}
66-
67-
await next()
68-
})
69-
}
7043

71-
export function createAdminMiddleware(auth: AuthInstance) {
72-
return createMiddleware<{
73-
Variables: {
74-
session: Session['session']
75-
user: Session['user']
76-
}
77-
}>(async (c, next) => {
78-
let session
79-
try {
80-
session = await auth.api.getSession({
81-
headers: c.req.raw.headers,
82-
})
83-
} catch (error) {
84-
logger.error('Session lookup failed', { error })
85-
return c.json({ error: 'Internal Server Error' }, 500)
86-
}
87-
88-
if (!session) {
89-
return c.json({ error: 'Unauthorized' }, 401)
90-
}
91-
92-
const user = session.user as Session['user']
93-
if (user.role !== 'admin') {
94-
return c.json({ error: 'Forbidden: Admin access required' }, 403)
95-
}
96-
97-
c.set('session', session.session as Session['session'])
98-
c.set('user', user)
99-
await next()
100-
})
101-
}

backend/src/db/migration-runner.ts

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ function markApplied(db: Database, migration: Migration): void {
3434
.run(migration.version, migration.name, Date.now())
3535
}
3636

37-
function markReverted(db: Database, version: number): void {
38-
db.prepare('DELETE FROM schema_migrations WHERE version = ?').run(version)
39-
}
40-
4137
export function migrate(db: Database, migrations: Migration[]): void {
4238
ensureMigrationsTable(db)
4339

@@ -70,49 +66,4 @@ export function migrate(db: Database, migrations: Migration[]): void {
7066
logger.info('All migrations applied successfully')
7167
}
7268

73-
export function rollback(db: Database, migrations: Migration[], targetVersion?: number): void {
74-
ensureMigrationsTable(db)
75-
76-
const applied = getAppliedVersions(db)
77-
const sorted = [...migrations]
78-
.filter(m => applied.has(m.version))
79-
.sort((a, b) => b.version - a.version)
80-
81-
if (sorted.length === 0) {
82-
logger.info('No migrations to rollback')
83-
return
84-
}
85-
86-
const latest = sorted[0]
87-
if (!latest) {
88-
logger.info('No migrations to rollback')
89-
return
90-
}
91-
const target = targetVersion ?? latest.version - 1
92-
93-
const toRevert = sorted.filter(m => m.version > target)
94-
95-
if (toRevert.length === 0) {
96-
logger.info('No migrations to rollback')
97-
return
98-
}
9969

100-
logger.info(`Rolling back ${toRevert.length} migration(s) to version ${target}`)
101-
102-
for (const migration of toRevert) {
103-
logger.info(`Reverting migration ${migration.version}: ${migration.name}`)
104-
db.run('BEGIN TRANSACTION')
105-
try {
106-
migration.down(db)
107-
markReverted(db, migration.version)
108-
db.run('COMMIT')
109-
logger.info(`Migration ${migration.version} reverted successfully`)
110-
} catch (error) {
111-
db.run('ROLLBACK')
112-
logger.error(`Rollback of migration ${migration.version} failed:`, error)
113-
throw error
114-
}
115-
}
116-
117-
logger.info('Rollback completed successfully')
118-
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Migration } from '../migration-runner'
2+
3+
interface ColumnInfo {
4+
name: string
5+
}
6+
7+
const migration: Migration = {
8+
version: 11,
9+
name: 'repo-last-accessed',
10+
11+
up(db) {
12+
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
13+
const existing = new Set(tableInfo.map((column) => column.name))
14+
15+
if (!existing.has('last_accessed_at')) {
16+
db.run('ALTER TABLE repos ADD COLUMN last_accessed_at INTEGER')
17+
}
18+
},
19+
20+
down(db) {
21+
void db
22+
},
23+
}
24+
25+
export default migration

backend/src/db/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import migration007 from './007-schedules'
99
import migration008 from './008-schedule-cron-support'
1010
import migration009 from './009-repo-source-path'
1111
import migration010 from './009-prompt-templates'
12+
import migration011 from './011-repo-last-accessed'
1213

1314
export const allMigrations: Migration[] = [
1415
migration001,
@@ -21,4 +22,5 @@ export const allMigrations: Migration[] = [
2122
migration008,
2223
migration009,
2324
migration010,
25+
migration011,
2426
]

backend/src/db/queries.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getReposPath } from '@opencode-manager/shared/config/env'
44
import { getErrorMessage } from '../utils/error-utils'
55
import path from 'path'
66

7-
export interface RepoRow {
7+
interface RepoRow {
88
id: number
99
repo_url?: string
1010
local_path: string
@@ -14,6 +14,7 @@ export interface RepoRow {
1414
clone_status: string
1515
cloned_at: number
1616
last_pulled?: number
17+
last_accessed_at?: number
1718
opencode_config_name?: string
1819
is_worktree?: number
1920
is_local?: number
@@ -33,12 +34,20 @@ function rowToRepo(row: RepoRow): Repo {
3334
cloneStatus: row.clone_status as Repo['cloneStatus'],
3435
clonedAt: row.cloned_at,
3536
lastPulled: row.last_pulled,
37+
lastAccessedAt: row.last_accessed_at,
3638
openCodeConfigName: row.opencode_config_name,
3739
isWorktree: row.is_worktree ? Boolean(row.is_worktree) : undefined,
3840
isLocal: row.is_local ? Boolean(row.is_local) : undefined,
3941
}
4042
}
4143

44+
export function getRepoById(db: Database, id: number): Repo | null {
45+
const stmt = db.prepare('SELECT * FROM repos WHERE id = ?')
46+
const row = stmt.get(id) as RepoRow | undefined
47+
48+
return row ? rowToRepo(row) : null
49+
}
50+
4251
export function createRepo(db: Database, repo: CreateRepoInput): Repo {
4352
const normalizedPath = repo.localPath.trim().replace(/\/+$/, '')
4453

@@ -53,8 +62,8 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
5362
}
5463

5564
const stmt = db.prepare(`
56-
INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local)
57-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
65+
INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, last_accessed_at, is_worktree, is_local)
66+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5867
`)
5968

6069
try {
@@ -66,6 +75,7 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
6675
repo.defaultBranch,
6776
repo.cloneStatus,
6877
repo.clonedAt,
78+
repo.clonedAt,
6979
repo.isWorktree ? 1 : 0,
7080
repo.isLocal ? 1 : 0
7181
)
@@ -96,13 +106,6 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
96106
}
97107
}
98108

99-
export function getRepoById(db: Database, id: number): Repo | null {
100-
const stmt = db.prepare('SELECT * FROM repos WHERE id = ?')
101-
const row = stmt.get(id) as RepoRow | undefined
102-
103-
return row ? rowToRepo(row) : null
104-
}
105-
106109
export function getRepoByUrlAndBranch(db: Database, repoUrl: string, branch?: string): Repo | null {
107110
const query = branch
108111
? 'SELECT * FROM repos WHERE repo_url = ? AND branch = ?'
@@ -189,6 +192,14 @@ export function updateLastPulled(db: Database, id: number): void {
189192
}
190193
}
191194

195+
export function updateLastAccessed(db: Database, id: number): void {
196+
const stmt = db.prepare('UPDATE repos SET last_accessed_at = ? WHERE id = ?')
197+
const result = stmt.run(Date.now(), id)
198+
if (result.changes === 0) {
199+
throw new Error(`Repository with id ${id} not found`)
200+
}
201+
}
202+
192203
export function updateRepoBranch(db: Database, id: number, branch: string): void {
193204
const stmt = db.prepare('UPDATE repos SET branch = ? WHERE id = ?')
194205
const result = stmt.run(branch, id)
@@ -201,3 +212,4 @@ export function deleteRepo(db: Database, id: number): void {
201212
const stmt = db.prepare('DELETE FROM repos WHERE id = ?')
202213
stmt.run(id)
203214
}
215+

backend/src/db/schedules.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,6 @@ export function deleteScheduleJob(db: Database, repoId: number, jobId: number):
204204
return result.changes > 0
205205
}
206206

207-
export function reserveScheduleJobNextRun(db: Database, repoId: number, jobId: number, nextRunAt: number): void {
208-
const stmt = db.prepare('UPDATE schedule_jobs SET next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?')
209-
stmt.run(nextRunAt, Date.now(), repoId, jobId)
210-
}
211-
212207
export function updateScheduleJobRunState(db: Database, repoId: number, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void {
213208
const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?')
214209
stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId)

0 commit comments

Comments
 (0)