Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

218
frontend/src/stores/git.ts Normal file
View File

@@ -0,0 +1,218 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { GitStatus, CommitInfo, CommitDetail, DiffInfo, RemoteInfo } from '../types'
import { gitApi } from '../api/client'
export const useGitStore = defineStore('git', () => {
// State
const status = ref<GitStatus | null>(null)
const loading = ref(false)
const committing = ref(false)
const pushing = ref(false)
const fetching = ref(false)
const error = ref<string | null>(null)
const conflicts = ref<string[]>([])
// New state for expanded git features
const history = ref<CommitDetail[]>([])
const historyLoading = ref(false)
const workingDiff = ref<DiffInfo | null>(null)
const diffLoading = ref(false)
const selectedCommitDiff = ref<DiffInfo | null>(null)
const selectedCommitId = ref<string | null>(null)
const remote = ref<RemoteInfo | null>(null)
const panelOpen = ref(false)
// Getters
const hasChanges = computed(() => status.value?.has_changes ?? false)
const hasConflicts = computed(() => conflicts.value.length > 0)
const branch = computed(() => status.value?.branch ?? 'main')
const isRepo = computed(() => status.value?.is_repo ?? false)
const changedFilesCount = computed(() => status.value?.files.length ?? 0)
const hasRemote = computed(() => remote.value !== null)
const canPush = computed(() => (remote.value?.ahead ?? 0) > 0)
const canPull = computed(() => (remote.value?.behind ?? 0) > 0)
// Actions
async function loadStatus() {
try {
loading.value = true
error.value = null
status.value = await gitApi.status()
} catch (err) {
error.value = `Failed to load git status: ${err}`
} finally {
loading.value = false
}
}
async function commit(message?: string): Promise<CommitInfo | null> {
try {
committing.value = true
error.value = null
const result = await gitApi.commit(message)
await loadStatus()
// Refresh history and diff after commit
await Promise.all([loadHistory(), loadWorkingDiff(), loadRemote()])
return result
} catch (err) {
error.value = `Commit failed: ${err}`
return null
} finally {
committing.value = false
}
}
async function push() {
try {
pushing.value = true
error.value = null
const result = await gitApi.push()
if (!result.success) {
throw new Error(result.message)
}
await Promise.all([loadStatus(), loadRemote()])
} catch (err) {
error.value = `Push failed: ${err}`
throw err
} finally {
pushing.value = false
}
}
async function fetchRemote() {
try {
fetching.value = true
error.value = null
const result = await gitApi.fetch()
if (!result.success) {
throw new Error(result.message)
}
await loadRemote()
} catch (err) {
error.value = `Fetch failed: ${err}`
throw err
} finally {
fetching.value = false
}
}
async function checkConflicts() {
try {
error.value = null
conflicts.value = await gitApi.conflicts()
} catch (err) {
// Conflicts endpoint might not exist yet, ignore error
conflicts.value = []
}
}
async function loadHistory(limit?: number) {
try {
historyLoading.value = true
history.value = await gitApi.log(limit)
} catch (err) {
console.error('Failed to load git history:', err)
history.value = []
} finally {
historyLoading.value = false
}
}
async function loadWorkingDiff() {
try {
diffLoading.value = true
workingDiff.value = await gitApi.diff()
} catch (err) {
console.error('Failed to load working diff:', err)
workingDiff.value = null
} finally {
diffLoading.value = false
}
}
async function loadCommitDiff(commitId: string) {
try {
diffLoading.value = true
selectedCommitId.value = commitId
selectedCommitDiff.value = await gitApi.commitDiff(commitId)
} catch (err) {
console.error('Failed to load commit diff:', err)
selectedCommitDiff.value = null
} finally {
diffLoading.value = false
}
}
async function loadRemote() {
try {
remote.value = await gitApi.remote()
} catch (err) {
console.error('Failed to load remote info:', err)
remote.value = null
}
}
function clearSelectedCommit() {
selectedCommitId.value = null
selectedCommitDiff.value = null
}
function togglePanel() {
panelOpen.value = !panelOpen.value
if (panelOpen.value) {
// Load all data when opening panel
Promise.all([
loadStatus(),
loadHistory(),
loadWorkingDiff(),
loadRemote()
])
}
}
function clearError() {
error.value = null
}
return {
// State
status,
loading,
committing,
pushing,
fetching,
error,
conflicts,
history,
historyLoading,
workingDiff,
diffLoading,
selectedCommitDiff,
selectedCommitId,
remote,
panelOpen,
// Getters
hasChanges,
hasConflicts,
branch,
isRepo,
changedFilesCount,
hasRemote,
canPush,
canPull,
// Actions
loadStatus,
commit,
push,
fetchRemote,
checkConflicts,
loadHistory,
loadWorkingDiff,
loadCommitDiff,
loadRemote,
clearSelectedCommit,
togglePanel,
clearError
}
})

View File

@@ -0,0 +1,8 @@
export { useNotesStore } from './notes'
export { useProjectsStore } from './projects'
export { useTasksStore } from './tasks'
export { useUiStore } from './ui'
export { useWebSocketStore } from './websocket'
export { useGitStore } from './git'
export { useWorkspaceStore } from './workspace'
export { useThemeStore } from './theme'

View File

@@ -0,0 +1,131 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Note, NoteSummary } from '../types'
import { notesApi } from '../api/client'
export const useNotesStore = defineStore('notes', () => {
// State
const notes = ref<NoteSummary[]>([])
const currentNote = ref<Note | null>(null)
const loading = ref(false)
const loadingNote = ref(false)
const error = ref<string | null>(null)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
// Getters
const sortedNotes = computed(() =>
[...notes.value].sort((a, b) => {
const dateA = a.updated ? new Date(a.updated).getTime() : 0
const dateB = b.updated ? new Date(b.updated).getTime() : 0
return dateB - dateA
})
)
const getNoteById = computed(() => (id: string) =>
notes.value.find(n => n.id === id)
)
// Actions
async function loadNotes() {
try {
loading.value = true
error.value = null
notes.value = await notesApi.list()
} catch (err) {
error.value = `Failed to load notes: ${err}`
} finally {
loading.value = false
}
}
async function loadNote(id: string) {
try {
loadingNote.value = true
error.value = null
currentNote.value = await notesApi.get(id)
saveStatus.value = 'idle'
} catch (err) {
error.value = `Failed to load note: ${err}`
currentNote.value = null
} finally {
loadingNote.value = false
}
}
async function createNote() {
try {
error.value = null
const newNote = await notesApi.create()
await loadNotes()
return newNote
} catch (err) {
error.value = `Failed to create note: ${err}`
throw err
}
}
async function saveNote(content: string) {
if (!currentNote.value) return
try {
saveStatus.value = 'saving'
currentNote.value = await notesApi.update(currentNote.value.id, content)
saveStatus.value = 'saved'
await loadNotes() // Refresh list to update timestamps
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
} catch (err) {
saveStatus.value = 'error'
error.value = `Failed to save note: ${err}`
throw err
}
}
async function deleteNote(id?: string) {
const noteId = id || currentNote.value?.id
if (!noteId) return
try {
error.value = null
await notesApi.delete(noteId)
if (currentNote.value?.id === noteId) {
currentNote.value = null
}
await loadNotes()
} catch (err) {
error.value = `Failed to archive note: ${err}`
throw err
}
}
function clearCurrentNote() {
currentNote.value = null
saveStatus.value = 'idle'
}
function clearError() {
error.value = null
}
return {
// State
notes,
currentNote,
loading,
loadingNote,
error,
saveStatus,
// Getters
sortedNotes,
getNoteById,
// Actions
loadNotes,
loadNote,
createNote,
saveNote,
deleteNote,
clearCurrentNote,
clearError
}
})

View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Project } from '../types'
import { projectsApi } from '../api/client'
export const useProjectsStore = defineStore('projects', () => {
// State
const projects = ref<Project[]>([])
const currentProject = ref<Project | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const sortedProjects = computed(() =>
[...projects.value].sort((a, b) => a.name.localeCompare(b.name))
)
const getProjectById = computed(() => (id: string) =>
projects.value.find(p => p.id === id)
)
// Actions
async function loadProjects() {
try {
loading.value = true
error.value = null
projects.value = await projectsApi.list()
} catch (err) {
error.value = `Failed to load projects: ${err}`
} finally {
loading.value = false
}
}
async function loadProject(id: string) {
try {
loading.value = true
error.value = null
currentProject.value = await projectsApi.get(id)
} catch (err) {
error.value = `Failed to load project: ${err}`
currentProject.value = null
} finally {
loading.value = false
}
}
async function createProject(name: string) {
try {
error.value = null
const newProject = await projectsApi.create(name)
await loadProjects()
return newProject
} catch (err) {
error.value = `Failed to create project: ${err}`
throw err
}
}
function clearCurrentProject() {
currentProject.value = null
}
function clearError() {
error.value = null
}
return {
// State
projects,
currentProject,
loading,
error,
// Getters
sortedProjects,
getProjectById,
// Actions
loadProjects,
loadProject,
createProject,
clearCurrentProject,
clearError
}
})

View File

@@ -0,0 +1,235 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Task, TaskWithContent } from '../types'
import { tasksApi } from '../api/client'
export const useTasksStore = defineStore('tasks', () => {
// State
const tasks = ref<Task[]>([])
const allTasks = ref<Task[]>([])
const currentProjectId = ref<string | null>(null)
const selectedTask = ref<TaskWithContent | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const activeTasks = computed(() =>
tasks.value.filter(t => !t.completed && t.section !== 'Backlog')
)
const completedTasks = computed(() =>
tasks.value.filter(t => t.completed || t.section === 'Completed')
)
const backlogTasks = computed(() =>
tasks.value.filter(t => !t.completed && t.section === 'Backlog')
)
const pendingTasks = computed(() =>
allTasks.value.filter(t => !t.completed)
)
const tasksByProject = computed(() => (projectId: string) =>
allTasks.value.filter(t => t.project_id === projectId)
)
/** All unique tags used across tasks in the current project */
const projectTags = computed(() => {
const tagSet = new Set<string>()
for (const task of tasks.value) {
if (task.tags) {
for (const tag of task.tags) {
tagSet.add(tag)
}
}
}
return [...tagSet].sort()
})
// Actions
async function loadAllTasks() {
try {
loading.value = true
error.value = null
allTasks.value = await tasksApi.listAll()
} catch (err) {
error.value = `Failed to load tasks: ${err}`
} finally {
loading.value = false
}
}
async function loadProjectTasks(projectId: string) {
try {
loading.value = true
error.value = null
currentProjectId.value = projectId
tasks.value = await tasksApi.list(projectId)
} catch (err) {
error.value = `Failed to load project tasks: ${err}`
} finally {
loading.value = false
}
}
async function loadTask(projectId: string, taskId: string) {
try {
error.value = null
selectedTask.value = await tasksApi.get(projectId, taskId)
} catch (err) {
error.value = `Failed to load task: ${err}`
selectedTask.value = null
}
}
async function createTask(projectId: string, title: string, section?: string, parentId?: string) {
try {
error.value = null
const task = await tasksApi.create(projectId, title, section, parentId)
// Refresh tasks list
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to create task: ${err}`
throw err
}
}
async function updateTaskContent(projectId: string, taskId: string, content: string) {
try {
error.value = null
const task = await tasksApi.updateContent(projectId, taskId, content)
selectedTask.value = task
// Refresh tasks list to update timestamps
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to update task: ${err}`
throw err
}
}
async function toggleTask(projectId: string, taskId: string) {
try {
error.value = null
await tasksApi.toggle(projectId, taskId)
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
// Update selected task if it's the one being toggled
if (selectedTask.value?.id === taskId) {
await loadTask(projectId, taskId)
}
} catch (err) {
error.value = `Failed to toggle task: ${err}`
throw err
}
}
async function updateTaskMeta(
projectId: string,
taskId: string,
meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }
) {
try {
error.value = null
await tasksApi.updateMeta(projectId, taskId, meta)
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
// Update selected task if it's the one being updated
if (selectedTask.value?.id === taskId) {
await loadTask(projectId, taskId)
}
} catch (err) {
error.value = `Failed to update task: ${err}`
throw err
}
}
async function deleteTask(projectId: string, taskId: string) {
try {
error.value = null
await tasksApi.delete(projectId, taskId)
// Clear selected task if it was deleted
if (selectedTask.value?.id === taskId) {
selectedTask.value = null
}
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
} catch (err) {
error.value = `Failed to delete task: ${err}`
throw err
}
}
function selectTask(task: Task | null) {
if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id)
} else {
selectedTask.value = null
}
}
function clearSelectedTask() {
selectedTask.value = null
}
function clearProjectTasks() {
tasks.value = []
currentProjectId.value = null
selectedTask.value = null
}
function clearError() {
error.value = null
}
return {
// State
tasks,
allTasks,
currentProjectId,
selectedTask,
loading,
error,
// Getters
activeTasks,
completedTasks,
backlogTasks,
pendingTasks,
tasksByProject,
projectTags,
// Actions
loadAllTasks,
loadProjectTasks,
loadTask,
createTask,
updateTaskContent,
toggleTask,
updateTaskMeta,
deleteTask,
selectTask,
clearSelectedTask,
clearProjectTasks,
clearError
}
})

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type ThemeMode = 'dark' | 'light' | 'system'
const STORAGE_KEY = 'ironpad-theme'
export const useThemeStore = defineStore('theme', () => {
// Default to dark mode
const mode = ref<ThemeMode>('dark')
// Load saved preference
function loadSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && ['dark', 'light', 'system'].includes(saved)) {
mode.value = saved as ThemeMode
}
}
// Get the effective theme (resolves 'system' to actual theme)
function getEffectiveTheme(): 'dark' | 'light' {
if (mode.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return mode.value
}
// Apply theme to document
function applyTheme() {
const effectiveTheme = getEffectiveTheme()
document.documentElement.setAttribute('data-theme', effectiveTheme)
// Also set class for easier CSS targeting
document.documentElement.classList.remove('theme-dark', 'theme-light')
document.documentElement.classList.add(`theme-${effectiveTheme}`)
}
// Set theme mode
function setTheme(newMode: ThemeMode) {
mode.value = newMode
localStorage.setItem(STORAGE_KEY, newMode)
applyTheme()
}
// Toggle between dark and light
function toggleTheme() {
const current = getEffectiveTheme()
setTheme(current === 'dark' ? 'light' : 'dark')
}
// Initialize
function init() {
loadSavedTheme()
// Ensure data-theme attribute is set (even if same as default)
applyTheme()
// Listen for system theme changes when in system mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (mode.value === 'system') {
applyTheme()
}
})
// Theme is now initialized
}
return {
mode,
getEffectiveTheme,
setTheme,
toggleTheme,
applyTheme,
init
}
})

125
frontend/src/stores/ui.ts Normal file
View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SearchResult } from '../types'
import { searchApi } from '../api/client'
export const useUiStore = defineStore('ui', () => {
// State
const showSearch = ref(false)
const showTasks = ref(false)
const showPreview = ref(false)
const searchQuery = ref('')
const searchResults = ref<SearchResult[]>([])
const isSearching = ref(false)
const globalError = ref<string | null>(null)
const sidebarSection = ref<'notes' | 'projects' | 'daily'>('notes')
// Getters
const hasSearchResults = computed(() => searchResults.value.length > 0)
// Actions
function openSearch() {
showSearch.value = true
showTasks.value = false
}
function closeSearch() {
showSearch.value = false
searchQuery.value = ''
searchResults.value = []
}
function toggleSearch() {
if (showSearch.value) {
closeSearch()
} else {
openSearch()
}
}
function openTasks() {
showTasks.value = true
showSearch.value = false
}
function closeTasks() {
showTasks.value = false
}
function toggleTasks() {
if (showTasks.value) {
closeTasks()
} else {
openTasks()
}
}
function togglePreview() {
showPreview.value = !showPreview.value
// Persist preference
localStorage.setItem('ironpad-show-preview', String(showPreview.value))
}
function loadPreviewPreference() {
const saved = localStorage.getItem('ironpad-show-preview')
if (saved !== null) {
showPreview.value = saved === 'true'
}
}
async function search(query: string) {
if (query.length < 2) {
searchResults.value = []
return
}
try {
isSearching.value = true
searchQuery.value = query
searchResults.value = await searchApi.search(query)
} catch (err) {
globalError.value = `Search failed: ${err}`
} finally {
isSearching.value = false
}
}
function setSidebarSection(section: 'notes' | 'projects' | 'daily') {
sidebarSection.value = section
}
function setGlobalError(message: string | null) {
globalError.value = message
}
function clearGlobalError() {
globalError.value = null
}
return {
// State
showSearch,
showTasks,
showPreview,
searchQuery,
searchResults,
isSearching,
globalError,
sidebarSection,
// Getters
hasSearchResults,
// Actions
openSearch,
closeSearch,
toggleSearch,
openTasks,
closeTasks,
toggleTasks,
togglePreview,
loadPreviewPreference,
search,
setSidebarSection,
setGlobalError,
clearGlobalError
}
})

View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { FileLock } from '../types'
export const useWebSocketStore = defineStore('websocket', () => {
// State
const connected = ref(false)
const clientId = ref<string | null>(null)
const fileLocks = ref<Map<string, FileLock>>(new Map())
const gitConflicts = ref<string[]>([])
// Actions
function setConnected(value: boolean) {
connected.value = value
}
function setClientId(id: string | null) {
clientId.value = id
}
function addFileLock(lock: FileLock) {
fileLocks.value.set(lock.path, lock)
}
function removeFileLock(path: string) {
fileLocks.value.delete(path)
}
function isFileLocked(path: string): FileLock | undefined {
return fileLocks.value.get(path)
}
function isFileLockedByOther(path: string): boolean {
const lock = fileLocks.value.get(path)
return lock !== undefined && lock.client_id !== clientId.value
}
function setGitConflicts(files: string[]) {
gitConflicts.value = files
}
function clearGitConflicts() {
gitConflicts.value = []
}
function clearAllLocks() {
fileLocks.value.clear()
}
return {
// State
connected,
clientId,
fileLocks,
gitConflicts,
// Actions
setConnected,
setClientId,
addFileLock,
removeFileLock,
isFileLocked,
isFileLockedByOther,
setGitConflicts,
clearGitConflicts,
clearAllLocks
}
})

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Project } from '../types'
import { projectsApi } from '../api/client'
const STORAGE_KEY = 'ironpad-active-project'
export const useWorkspaceStore = defineStore('workspace', () => {
// State
const activeProjectId = ref<string | null>(null)
const activeProject = ref<Project | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const hasActiveProject = computed(() => activeProjectId.value !== null)
// Actions
async function setActiveProject(projectId: string | null) {
if (projectId === activeProjectId.value) return
activeProjectId.value = projectId
if (projectId) {
// Persist to localStorage
localStorage.setItem(STORAGE_KEY, projectId)
// Load project details
try {
loading.value = true
error.value = null
activeProject.value = await projectsApi.get(projectId)
} catch (err) {
error.value = `Failed to load project: ${err}`
activeProject.value = null
} finally {
loading.value = false
}
} else {
localStorage.removeItem(STORAGE_KEY)
activeProject.value = null
}
}
async function loadSavedProject() {
const savedId = localStorage.getItem(STORAGE_KEY)
if (savedId) {
await setActiveProject(savedId)
}
}
function clearActiveProject() {
activeProjectId.value = null
activeProject.value = null
localStorage.removeItem(STORAGE_KEY)
}
function clearError() {
error.value = null
}
return {
// State
activeProjectId,
activeProject,
loading,
error,
// Getters
hasActiveProject,
// Actions
setActiveProject,
loadSavedProject,
clearActiveProject,
clearError
}
})