Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@ src-tauri/target/
# Worktrees
.worktrees/

# Superpowers brainstorm sessions
.superpowers/

# Docs (superpowers plans/specs)
docs/
.claude/worktrees/
5 changes: 5 additions & 0 deletions plugins/backlinks/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
local prism = require("prism")

prism.on("file:opened", function(payload)
prism.log("Backlinks: file opened - " .. (payload.path or ""))
end)
8 changes: 8 additions & 0 deletions plugins/backlinks/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[plugin]
name = "backlinks"
version = "0.1.0"
description = "Show files that link to the current note"
author = "Prism"

[ui]
sidebar = true
5 changes: 5 additions & 0 deletions plugins/outline/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
local prism = require("prism")

prism.on("file:opened", function(payload)
prism.log("Outline: file opened - " .. (payload.path or ""))
end)
8 changes: 8 additions & 0 deletions plugins/outline/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[plugin]
name = "outline"
version = "0.1.0"
description = "Table of contents for the current note"
author = "Prism"

[ui]
sidebar = true
15 changes: 13 additions & 2 deletions src-tauri/src/commands/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,21 @@ pub fn toggle_todo(
}

#[tauri::command]
pub fn write_file(path: String, content: String, config: State<'_, Mutex<PrismConfig>>) -> Result<(), String> {
pub fn write_file(
path: String,
content: String,
config: State<'_, Mutex<PrismConfig>>,
lua_runtime: State<'_, Mutex<LuaRuntime>>,
) -> Result<(), String> {
let config = config.lock().map_err(|e| e.to_string())?;
let full_path = config.vault_path().join(&path);
fs::write(&full_path, content).map_err(|e| format!("Failed to write {}: {}", path, e))
fs::write(&full_path, &content).map_err(|e| format!("Failed to write {}: {}", path, e))?;

if let Ok(runtime) = lua_runtime.lock() {
runtime.dispatch("file:saved", Some(serde_json::json!({ "path": path })));
}

Ok(())
}

#[tauri::command]
Expand Down
132 changes: 132 additions & 0 deletions src-tauri/src/commands/frecency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use tauri::State;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
pub count: u32,
pub last_opened: i64,
pub score: f64,
}

pub struct FrecencyStore {
entries: RwLock<HashMap<String, FrecencyEntry>>,
file_path: RwLock<PathBuf>,
dirty: RwLock<bool>,
}

impl FrecencyStore {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
file_path: RwLock::new(PathBuf::new()),
dirty: RwLock::new(false),
}
}

pub fn init(&self, vault_path: &str) {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
vault_path.hash(&mut hasher);
let hash = hasher.finish();

let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("prism");
let _ = std::fs::create_dir_all(&config_dir);

let path = config_dir.join(format!("frecency-{:x}.json", hash));

if path.exists() {
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(entries) = serde_json::from_str::<HashMap<String, FrecencyEntry>>(&data)
{
*self.entries.write().unwrap() = entries;
}
}
}

*self.file_path.write().unwrap() = path;
}

pub fn record_open(&self, path: &str) {
let now = chrono::Utc::now().timestamp();
let mut entries = self.entries.write().unwrap();
let entry = entries.entry(path.to_string()).or_insert(FrecencyEntry {
count: 0,
last_opened: 0,
score: 0.0,
});
entry.count += 1;
entry.last_opened = now;
entry.score = self.compute_score(entry.count, entry.last_opened, now);
*self.dirty.write().unwrap() = true;
}

pub fn flush(&self) {
let dirty = *self.dirty.read().unwrap();
if !dirty {
return;
}
let file_path = self.file_path.read().unwrap().clone();
if file_path.as_os_str().is_empty() {
return;
}
let entries = self.entries.read().unwrap();
if let Ok(json) = serde_json::to_string_pretty(&*entries) {
let _ = std::fs::write(&file_path, json);
}
*self.dirty.write().unwrap() = false;
}

pub fn get_scores(&self) -> HashMap<String, f64> {
let now = chrono::Utc::now().timestamp();
let entries = self.entries.read().unwrap();
entries
.iter()
.map(|(path, entry)| {
(
path.clone(),
self.compute_score(entry.count, entry.last_opened, now),
)
})
.collect()
}

pub fn get_recent(&self, limit: usize) -> Vec<String> {
let entries = self.entries.read().unwrap();
let mut sorted: Vec<_> = entries.iter().collect();
sorted.sort_by(|a, b| b.1.last_opened.cmp(&a.1.last_opened));
sorted.into_iter().take(limit).map(|(k, _)| k.clone()).collect()
}

fn compute_score(&self, count: u32, last_opened: i64, now: i64) -> f64 {
let days_ago = ((now - last_opened) as f64) / 86400.0;
// Halve score every 7 days
let decay = 0.5_f64.powf(days_ago / 7.0);
(count as f64) * decay
}
}

#[tauri::command]
pub fn record_file_open(path: String, frecency: State<'_, Arc<FrecencyStore>>) {
frecency.record_open(&path);
frecency.flush();
}

#[tauri::command]
pub fn get_frecency_scores(
frecency: State<'_, Arc<FrecencyStore>>,
) -> HashMap<String, f64> {
frecency.get_scores()
}

#[tauri::command]
pub fn get_recent_files(
limit: usize,
frecency: State<'_, Arc<FrecencyStore>>,
) -> Vec<String> {
frecency.get_recent(limit)
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod config;
pub mod editor;
pub mod favorites;
pub mod files;
pub mod frecency;
pub mod graph;
pub mod images;
pub mod logger;
Expand Down
58 changes: 43 additions & 15 deletions src-tauri/src/commands/search.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
use crate::config::PrismConfig;
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher,
};
use serde::Serialize;
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use tauri::State;

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -43,20 +42,53 @@ fn collect_md_files(dir: &Path, vault_root: &Path, out: &mut Vec<(String, String
}
}

pub struct FileIndex {
entries: RwLock<Vec<(String, String)>>,
vault_path: RwLock<PathBuf>,
}

impl FileIndex {
pub fn new() -> Self {
Self {
entries: RwLock::new(Vec::new()),
vault_path: RwLock::new(PathBuf::new()),
}
}

pub fn refresh(&self, vault_path: &Path) {
{
let mut vp = self.vault_path.write().unwrap();
*vp = vault_path.to_path_buf();
}
let mut files = Vec::new();
collect_md_files(vault_path, vault_path, &mut files);
let mut entries = self.entries.write().unwrap();
*entries = files;
}

pub fn refresh_current(&self) {
let vp = self.vault_path.read().unwrap().clone();
if vp.as_os_str().is_empty() {
return;
}
self.refresh(&vp);
}

pub fn get_entries(&self) -> Vec<(String, String)> {
self.entries.read().unwrap().clone()
}
}

#[tauri::command]
pub fn fuzzy_search(
query: String,
config: State<'_, Mutex<PrismConfig>>,
file_index: State<'_, Arc<FileIndex>>,
) -> Result<Vec<SearchResult>, String> {
if query.is_empty() {
return Ok(vec![]);
}

let config = config.lock().map_err(|e| e.to_string())?;
let vault = config.vault_path();

let mut files: Vec<(String, String)> = Vec::new();
collect_md_files(&vault, &vault, &mut files);
let files = file_index.get_entries();

let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
Expand Down Expand Up @@ -121,17 +153,13 @@ pub struct VaultSearchMatch {
#[tauri::command]
pub fn vault_search(
query: String,
config: State<'_, Mutex<PrismConfig>>,
file_index: State<'_, Arc<FileIndex>>,
) -> Result<Vec<VaultSearchMatch>, String> {
if query.is_empty() {
return Ok(vec![]);
}

let config = config.lock().map_err(|e| e.to_string())?;
let vault = config.vault_path();

let mut files: Vec<(String, String)> = Vec::new();
collect_md_files(&vault, &vault, &mut files);
let files = file_index.get_entries();

let query_lower = query.to_lowercase();
let mut results: Vec<VaultSearchMatch> = Vec::new();
Expand Down
29 changes: 27 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ mod plugins;
mod theme;
mod watcher;

use commands::frecency::FrecencyStore;
use commands::search::FileIndex;
use config::PrismConfig;
use log::{error, info};
use plugins::lua_runtime::LuaRuntime;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use tauri::Emitter;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};

Expand Down Expand Up @@ -136,11 +138,13 @@ pub fn run() {
}
});
})
.manage(Arc::new(FileIndex::new()))
.manage(Arc::new(FrecencyStore::new()))
.manage(Mutex::new(config))
.manage(Mutex::new(plugin_manager))
.manage(Mutex::new(lua_runtime))
.setup(move |app| {
use tauri::Manager;
use tauri::{Listener, Manager};

let config = app.state::<Mutex<PrismConfig>>();
let config = config.lock().unwrap();
Expand Down Expand Up @@ -180,6 +184,24 @@ pub fn run() {
let config_path = PrismConfig::config_path();
let handle = app.handle().clone();

// Initialize file index cache
let file_index = app.state::<Arc<FileIndex>>();
if vault_path.exists() {
file_index.refresh(&vault_path);
}

// Initialize frecency store
let frecency = app.state::<Arc<FrecencyStore>>();
frecency.init(&vault_path.to_string_lossy());

// Wire file watcher to refresh file index on changes
{
let file_index = Arc::clone(file_index.inner());
app.listen("file-changed", move |_| {
file_index.refresh_current();
});
}

if vault_path.exists() {
let watcher = watcher::start_watcher(handle, &vault_path, &config_path)
.expect("Failed to start file watcher");
Expand Down Expand Up @@ -259,6 +281,9 @@ pub fn run() {
commands::plugins::clean_plugins,
commands::plugins::plugin_emit,
commands::logger::log_message,
commands::frecency::record_file_open,
commands::frecency::get_frecency_scores,
commands::frecency::get_recent_files,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
Loading
Loading