From 7309fc17541cd563123be55a9ea64835e0484ce5 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Thu, 13 Nov 2025 17:06:27 +0100 Subject: [PATCH 01/24] feat (backend): pagination in list resource ids by tags apis Signed-off-by: Aavash Shrestha --- packages/backend/src/api/message.rs | 4 +- packages/backend/src/api/mod.rs | 20 +++ packages/backend/src/api/store.rs | 82 ++++++----- packages/backend/src/store/models.rs | 13 ++ packages/backend/src/store/resource_tags.rs | 133 ++++++++++++++++++ packages/backend/src/utils.rs | 50 ++++--- .../backend/src/worker/handlers/resource.rs | 40 +++--- packages/backend/src/worker/processor.rs | 75 ++++++---- 8 files changed, 309 insertions(+), 108 deletions(-) diff --git a/packages/backend/src/api/message.rs b/packages/backend/src/api/message.rs index 5dabdad9d..ae174f14f 100644 --- a/packages/backend/src/api/message.rs +++ b/packages/backend/src/api/message.rs @@ -114,8 +114,8 @@ pub enum ResourceMessage { RemoveResources(Vec), RemoveResourcesByTags(Vec), RecoverResource(String), - ListResourcesByTags(Vec), - ListResourcesByTagsNoSpace(Vec), + ListResourcesByTags(Vec, PaginationParams), + ListResourcesByTagsNoSpace(Vec, PaginationParams), ListAllResourcesAndSpaces(Vec), SearchResources(SearchResourcesParams), UpdateResource(Resource), diff --git a/packages/backend/src/api/mod.rs b/packages/backend/src/api/mod.rs index dc07b7f7b..50e0730ab 100644 --- a/packages/backend/src/api/mod.rs +++ b/packages/backend/src/api/mod.rs @@ -14,3 +14,23 @@ pub fn register_exported_functions(cx: &mut ModuleContext) -> NeonResult<()> { kv::register_exported_functions(cx)?; Ok(()) } + +pub fn parse_json_argument( + cx: &mut FunctionContext, + arg_index: usize, + arg_name: &str, +) -> Result { + let json_string = cx + .argument_opt(arg_index) + .and_then(|arg| arg.downcast::(cx).ok()) + .map(|js_string| js_string.value(cx)); + + match json_string + .map(|json_str| serde_json::from_str(&json_str)) + .transpose() + { + Ok(Some(value)) => Ok(value), + Ok(None) => Err(format!("{} must be provided", arg_name)), + Err(err) => Err(err.to_string()), + } +} diff --git a/packages/backend/src/api/store.rs b/packages/backend/src/api/store.rs index 32fe3976c..24307a995 100644 --- a/packages/backend/src/api/store.rs +++ b/packages/backend/src/api/store.rs @@ -1,5 +1,5 @@ -use crate::store::models::SearchResourcesParams; -use crate::{api::message::*, store::models, worker::tunnel::WorkerTunnel}; +use super::{message::*, parse_json_argument}; +use crate::{store::models, worker::tunnel::WorkerTunnel}; use neon::prelude::*; use neon::types::JsDate; @@ -469,22 +469,24 @@ fn js_recover_resource(mut cx: FunctionContext) -> JsResult { fn js_list_resources_by_tags(mut cx: FunctionContext) -> JsResult { let tunnel = cx.argument::>(0)?; - let resource_tags_json = cx - .argument_opt(1) - .and_then(|arg| arg.downcast::(&mut cx).ok()) - .map(|js_string| js_string.value(&mut cx)); - let resource_tags: Vec = match resource_tags_json - .map(|json_str| serde_json::from_str(&json_str)) - .transpose() - { - Ok(Some(tags)) => tags, - Ok(None) => return cx.throw_error("Resource tags must be provided"), - Err(err) => return cx.throw_error(err.to_string()), - }; + let resource_tags: Vec = + match parse_json_argument(&mut cx, 1, "Resource tags") { + Ok(tags) => tags, + Err(err) => return cx.throw_error(err), + }; + + let pagination_params: models::PaginationParams = + match parse_json_argument(&mut cx, 2, "Pagination parameters") { + Ok(params) => params, + Err(err) => return cx.throw_error(err), + }; let (deferred, promise) = cx.promise(); tunnel.worker_send_js( - WorkerMessage::ResourceMessage(ResourceMessage::ListResourcesByTags(resource_tags)), + WorkerMessage::ResourceMessage(ResourceMessage::ListResourcesByTags( + resource_tags, + pagination_params, + )), deferred, ); @@ -494,22 +496,24 @@ fn js_list_resources_by_tags(mut cx: FunctionContext) -> JsResult { fn js_list_resources_by_tags_no_space(mut cx: FunctionContext) -> JsResult { let tunnel = cx.argument::>(0)?; - let resource_tags_json = cx - .argument_opt(1) - .and_then(|arg| arg.downcast::(&mut cx).ok()) - .map(|js_string| js_string.value(&mut cx)); - let resource_tags: Vec = match resource_tags_json - .map(|json_str| serde_json::from_str(&json_str)) - .transpose() - { - Ok(Some(tags)) => tags, - Ok(None) => return cx.throw_error("Resource tags must be provided"), - Err(err) => return cx.throw_error(err.to_string()), - }; + let resource_tags: Vec = + match parse_json_argument(&mut cx, 1, "Resource tags") { + Ok(tags) => tags, + Err(err) => return cx.throw_error(err), + }; + + let pagination_params: models::PaginationParams = + match parse_json_argument(&mut cx, 2, "Pagination parameters") { + Ok(params) => params, + Err(err) => return cx.throw_error(err), + }; let (deferred, promise) = cx.promise(); tunnel.worker_send_js( - WorkerMessage::ResourceMessage(ResourceMessage::ListResourcesByTagsNoSpace(resource_tags)), + WorkerMessage::ResourceMessage(ResourceMessage::ListResourcesByTagsNoSpace( + resource_tags, + pagination_params, + )), deferred, ); @@ -591,16 +595,18 @@ fn js_search_resources(mut cx: FunctionContext) -> JsResult { let (deferred, promise) = cx.promise(); tunnel.worker_send_js( - WorkerMessage::ResourceMessage(ResourceMessage::SearchResources(SearchResourcesParams { - query, - resource_tag_filters, - semantic_search_enabled, - embeddings_distance_threshold, - embeddings_limit, - include_annotations, - space_id, - keyword_limit, - })), + WorkerMessage::ResourceMessage(ResourceMessage::SearchResources( + models::SearchResourcesParams { + query, + resource_tag_filters, + semantic_search_enabled, + embeddings_distance_threshold, + embeddings_limit, + include_annotations, + space_id, + keyword_limit, + }, + )), deferred, ); diff --git a/packages/backend/src/store/models.rs b/packages/backend/src/store/models.rs index 92d72f1fc..3d949b07a 100644 --- a/packages/backend/src/store/models.rs +++ b/packages/backend/src/store/models.rs @@ -1000,3 +1000,16 @@ pub struct App { pub icon: Option, pub meta: Option, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct PaginationParams { + pub limit: usize, + pub cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PaginatedResult { + pub items: Vec, + pub next_cursor: Option, + pub has_more: bool, +} diff --git a/packages/backend/src/store/resource_tags.rs b/packages/backend/src/store/resource_tags.rs index 2c671ebce..90246a151 100644 --- a/packages/backend/src/store/resource_tags.rs +++ b/packages/backend/src/store/resource_tags.rs @@ -214,6 +214,73 @@ impl Database { Ok(result) } + pub fn list_resource_ids_by_tags_paginated( + &self, + tags: &Vec, + pagination: PaginationParams, + ) -> BackendResult> { + if tags.is_empty() { + return Ok(PaginatedResult { + items: Vec::new(), + next_cursor: None, + has_more: false, + }); + } + + let (mut query, mut params) = list_resource_ids_by_tags_query(tags, 0); + + let param_offset = params.len(); + query = format!( + "SELECT r.id, r.created_at + FROM ({}) rt + JOIN resources r ON rt.resource_id = r.id + WHERE r.deleted = 0", + query + ); + + if let Some(cursor_id) = &pagination.cursor { + query = format!("{} AND r.id < ?{}", query, param_offset + 1); + params.push(cursor_id.clone()); + } + + // NOTE: created_at DESC is the primary ordering, but we need a secondary ordering on id DESC + query = format!( + "{} ORDER BY r.created_at DESC, r.id DESC LIMIT ?{}", + query, + params.len() + 1 + ); + // fetch one extra to check has_more + params.push((pagination.limit + 1).to_string()); + + let mut stmt = self.conn.prepare(&query)?; + let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?; + + let mut items = Vec::new(); + + while let Some(row) = rows.next()? { + let id: String = row.get(0)?; + items.push(id); + } + + // check if there are more results and remove the extra item + let has_more = items.len() > pagination.limit; + if has_more { + items.pop(); + } + + let next_cursor = if has_more { + items.last().cloned() + } else { + None + }; + + Ok(PaginatedResult { + items, + next_cursor, + has_more, + }) + } + pub fn list_resource_ids_by_tags_space_id( &self, tags: &Vec, @@ -266,6 +333,72 @@ impl Database { } Ok(result) } + + pub fn list_resource_ids_by_tags_no_space_paginated( + &self, + tags: &Vec, + pagination: PaginationParams, + ) -> BackendResult> { + if tags.is_empty() { + return Ok(PaginatedResult { + items: Vec::new(), + next_cursor: None, + has_more: false, + }); + } + + let (mut query, mut params) = list_resource_ids_by_tags_query(tags, 0); + + query = format!( + "SELECT rt.resource_id, r.created_at + FROM ({}) rt + JOIN resources r ON rt.resource_id = r.id + WHERE rt.resource_id NOT IN (SELECT resource_id FROM space_entries WHERE manually_added = 1)", + query + ); + + if let Some(cursor_id) = &pagination.cursor { + query = format!("{} AND rt.resource_id < ?{}", query, params.len() + 1); + params.push(cursor_id.clone()); + } + + // NOTE: created_at DESC is the primary ordering, but we need a secondary ordering on id DESC + query = format!( + "{} ORDER BY r.created_at DESC, rt.resource_id DESC LIMIT ?{}", + query, + params.len() + 1 + ); + // fetch one extra to check has_more + params.push((pagination.limit + 1).to_string()); + + let mut stmt = self.conn.prepare(&query)?; + let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?; + + let mut items = Vec::new(); + + while let Some(row) = rows.next()? { + let id: String = row.get(0)?; + items.push(id); + } + + // check if there are more results and remove the extra item + let has_more = items.len() > pagination.limit; + if has_more { + items.pop(); + } + + let next_cursor = if has_more { + items.last().cloned() + } else { + None + }; + + Ok(PaginatedResult { + items, + next_cursor, + has_more, + }) + } } #[cfg(test)] diff --git a/packages/backend/src/utils.rs b/packages/backend/src/utils.rs index a982d9183..43ef6905e 100644 --- a/packages/backend/src/utils.rs +++ b/packages/backend/src/utils.rs @@ -8,14 +8,14 @@ pub fn uuid_to_base62(uuid: &str) -> String { // Remove hyphens and convert to u128 let uuid = uuid.replace("-", ""); let mut num = u128::from_str_radix(&uuid, 16).unwrap_or(0); - + let mut result = Vec::new(); while num > 0 { let rem = (num % 62) as usize; result.push(BASE62_CHARS[rem]); num /= 62; } - + // Reverse and convert to string String::from_utf8(result.into_iter().rev().collect()).unwrap_or_default() } @@ -34,15 +34,11 @@ pub fn sanitize_filename(name: &str) -> String { /// Otherwise falls back to using just the resource ID pub fn get_resource_filename(resource_id: &str, metadata_name: Option<&str>) -> String { if let Some(name) = metadata_name { - let short_name = if name.len() > 150 { - &name[..150] - } else { - name - }; - + let short_name = if name.len() > 150 { &name[..150] } else { name }; + let sanitized_name = sanitize_filename(short_name); let short_id = uuid_to_base62(resource_id); - + format!("{}-{}", sanitized_name, short_id) } else { resource_id.to_string() @@ -50,7 +46,7 @@ pub fn get_resource_filename(resource_id: &str, metadata_name: Option<&str>) -> } /// Converts a resource type (MIME type) to a file extension. -/// +/// /// This function attempts to determine an appropriate file extension for a given resource type /// using the following logic: /// 1. For special space types (application/vnd.space.*), returns "jsong" @@ -79,10 +75,13 @@ pub fn get_resource_file_extension(resource_type: &str) -> String { "application/vnd.space.link", "application/vnd.space.article", "application/vnd.space.post", - "application/vnd.space.document.space-note" + "application/vnd.space.document.space-note", ]; - - if markdown_resource_types.iter().any(|&t| resource_type.starts_with(t)) { + + if markdown_resource_types + .iter() + .any(|&t| resource_type.starts_with(t)) + { return "md".to_string(); } @@ -114,23 +113,23 @@ mod tests { #[test] fn test_get_resource_filename() { let id = "550e8400-e29b-41d4-a716-446655440000"; - + // Test with metadata name let result = get_resource_filename(id, Some("My Test File")); assert!(result.starts_with("My Test File-")); assert!(result.contains('-')); - + // Test with problematic characters let result = get_resource_filename(id, Some("MyFile*.txt")); assert!(result.starts_with("My-Test-File-")); assert!(!result.contains('*')); assert!(!result.contains('<')); assert!(!result.contains('>')); - + // Test fallback to id let result = get_resource_filename(id, None); assert_eq!(result, id); - + // Test with leading periods let result = get_resource_filename(id, Some("...test")); assert!(result.starts_with("test-")); @@ -145,11 +144,20 @@ mod tests { #[test] fn test_get_resource_file_extension() { // Test space types - assert_eq!(get_resource_file_extension("application/vnd.space.article"), "md"); - assert_eq!(get_resource_file_extension("application/vnd.space.something"), "json"); + assert_eq!( + get_resource_file_extension("application/vnd.space.article"), + "md" + ); + assert_eq!( + get_resource_file_extension("application/vnd.space.something"), + "json" + ); // Test document space note - assert_eq!(get_resource_file_extension("application/vnd.space.document.space-note"), "md"); + assert_eq!( + get_resource_file_extension("application/vnd.space.document.space-note"), + "md" + ); // Test common MIME types assert_eq!(get_resource_file_extension("image/png"), "png"); @@ -161,4 +169,4 @@ mod tests { // Test fallback behavior assert_eq!(get_resource_file_extension("unknown/type"), "type"); } -} \ No newline at end of file +} diff --git a/packages/backend/src/worker/handlers/resource.rs b/packages/backend/src/worker/handlers/resource.rs index cd15596bd..3270f04b1 100644 --- a/packages/backend/src/worker/handlers/resource.rs +++ b/packages/backend/src/worker/handlers/resource.rs @@ -8,11 +8,11 @@ use crate::{ db::Database, models::{ current_time, random_uuid, CompositeResource, EmbeddingResource, EmbeddingType, - InternalResourceTagNames, PostProcessingJob, Resource, ResourceMetadata, - ResourceOrSpace, ResourceProcessingState, ResourceTag, ResourceTagFilter, - ResourceTextContentMetadata, ResourceTextContentType, SearchEngine, - SearchResourcesParams, SearchResult, SearchResultItem, SearchResultSimple, - SearchResultSpaceItem, SpaceEntryExtended, SpaceEntryType, + InternalResourceTagNames, PaginatedResult, PaginationParams, PostProcessingJob, + Resource, ResourceMetadata, ResourceOrSpace, ResourceProcessingState, ResourceTag, + ResourceTagFilter, ResourceTextContentMetadata, ResourceTextContentType, SearchEngine, + SearchResourcesParams, SearchResult, SearchResultItem, SearchResultSpaceItem, + SpaceEntryExtended, SpaceEntryType, }, }, worker::{send_worker_response, Worker}, @@ -32,17 +32,17 @@ impl Worker { let resource_id = random_uuid(); let ct = current_time(); - let extension = crate::utils::get_resource_file_extension(&resource_type); + let extension = crate::utils::get_resource_file_extension(&resource_type); let name = metadata.as_ref().map(|m| m.name.as_ref()); let resource_name = crate::utils::get_resource_filename(&resource_id, name); let resource = Resource { id: resource_id.clone(), resource_path: Path::new(&self.resources_path) - .join(format!("{}.{}", resource_name, extension)) - .as_os_str() - .to_string_lossy() - .to_string(), + .join(format!("{}.{}", resource_name, extension)) + .as_os_str() + .to_string_lossy() + .to_string(), resource_type: resource_type.clone(), created_at: ct, updated_at: ct, @@ -232,8 +232,10 @@ impl Worker { pub fn list_resources_by_tags( &mut self, tags: Vec, - ) -> BackendResult { - self.db.list_resources_by_tags(tags) + pagination: PaginationParams, + ) -> BackendResult> { + self.db + .list_resource_ids_by_tags_paginated(&tags, pagination) } #[instrument(level = "trace", skip(self))] @@ -248,8 +250,10 @@ impl Worker { pub fn list_resources_by_tags_no_space( &mut self, tags: Vec, - ) -> BackendResult { - self.db.list_resources_by_tags_no_space(tags) + pagination: PaginationParams, + ) -> BackendResult> { + self.db + .list_resource_ids_by_tags_no_space_paginated(&tags, pagination) } fn get_filtered_ids_for_search( @@ -753,16 +757,16 @@ pub fn handle_resource_message( let result = worker.remove_resources_by_tags(tags); send_worker_response(&mut worker.channel, oneshot, result); } - ResourceMessage::ListResourcesByTags(tags) => { - let result = worker.list_resources_by_tags(tags); + ResourceMessage::ListResourcesByTags(tags, pagination_params) => { + let result = worker.list_resources_by_tags(tags, pagination_params); send_worker_response(&mut worker.channel, oneshot, result); } ResourceMessage::ListAllResourcesAndSpaces(tags) => { let result = worker.list_all_resources_and_spaces(tags); send_worker_response(&mut worker.channel, oneshot, result); } - ResourceMessage::ListResourcesByTagsNoSpace(tags) => { - let result = worker.list_resources_by_tags_no_space(tags); + ResourceMessage::ListResourcesByTagsNoSpace(tags, pagination_params) => { + let result = worker.list_resources_by_tags_no_space(tags, pagination_params); send_worker_response(&mut worker.channel, oneshot, result); } ResourceMessage::SearchResources(search_params) => { diff --git a/packages/backend/src/worker/processor.rs b/packages/backend/src/worker/processor.rs index 664d98729..de31ed10d 100644 --- a/packages/backend/src/worker/processor.rs +++ b/packages/backend/src/worker/processor.rs @@ -268,21 +268,22 @@ fn is_markdown_file(file_name: &str) -> bool { fn parse_markdown_with_frontmatter(content: &str) -> BackendResult<(String, serde_yaml::Value)> { // Simple frontmatter parser - finds content between --- markers let parts: Vec<&str> = content.split("---").collect(); - + match parts.len() { // No frontmatter or invalid format 0 | 1 => Ok((content.to_string(), serde_yaml::Value::Null)), - + // Has frontmatter _ => { // Parse the YAML frontmatter (second part, index 1) let frontmatter_yaml = parts[1].trim(); - let frontmatter = serde_yaml::from_str(frontmatter_yaml) - .map_err(|e| BackendError::GenericError(format!("Failed to parse frontmatter: {}", e)))?; - + let frontmatter = serde_yaml::from_str(frontmatter_yaml).map_err(|e| { + BackendError::GenericError(format!("Failed to parse frontmatter: {}", e)) + })?; + // Get the content (everything after second ---) let content = parts[2..].join("---").trim().to_string(); - + Ok((content, frontmatter)) } } @@ -339,53 +340,68 @@ fn process_resource_data( } } - ResourceTextContentType::Post => { - process_file_data::(resource_data, resource_text_content_type, resource, |post_data| { + ResourceTextContentType::Post => process_file_data::( + resource_data, + resource_text_content_type, + resource, + |post_data| { let title = post_data.title.as_deref().unwrap_or_default(); let excerpt = post_data.excerpt.as_deref().unwrap_or_default(); let content = post_data.content_plain.as_deref().unwrap_or_default(); let author = post_data.author.as_deref().unwrap_or_default(); let site = post_data.site_name.as_deref().unwrap_or_default(); format!("{title} {excerpt} {content} {author} {site}") - }) - } + }, + ), - ResourceTextContentType::ChatMessage => { - process_file_data::(resource_data, resource_text_content_type, resource, |msg| { + ResourceTextContentType::ChatMessage => process_file_data::( + resource_data, + resource_text_content_type, + resource, + |msg| { let author = msg.author.as_deref().unwrap_or_default(); let content = msg.content_plain.as_deref().unwrap_or_default(); let platform = msg.platform_name.as_deref().unwrap_or_default(); format!("{author} {content} {platform}") - }) - } + }, + ), - ResourceTextContentType::Document => { - process_file_data::(resource_data, resource_text_content_type, resource, |doc| { + ResourceTextContentType::Document => process_file_data::( + resource_data, + resource_text_content_type, + resource, + |doc| { let author = doc.author.as_deref().unwrap_or_default(); let content = doc.content_plain.as_deref().unwrap_or_default(); let editor = doc.editor_name.as_deref().unwrap_or_default(); format!("{author} {content} {editor}") - }) - } + }, + ), - ResourceTextContentType::Article => { - process_file_data::(resource_data, resource_text_content_type, resource, |article| { + ResourceTextContentType::Article => process_file_data::( + resource_data, + resource_text_content_type, + resource, + |article| { let title = article.title.as_deref().unwrap_or_default(); let excerpt = article.excerpt.as_deref().unwrap_or_default(); let content = article.content_plain.as_deref().unwrap_or_default(); format!("{title} {excerpt} {content}") - }) - } + }, + ), - ResourceTextContentType::Link => { - process_file_data::(resource_data, resource_text_content_type, resource, |link| { + ResourceTextContentType::Link => process_file_data::( + resource_data, + resource_text_content_type, + resource, + |link| { let title = link.title.as_deref().unwrap_or_default(); let desc = link.description.as_deref().unwrap_or_default(); let url = link.url.as_deref().unwrap_or_default(); let content = link.content_plain.as_deref().unwrap_or_default(); format!("{title} {desc} {url}\n{content}") - }) - } + }, + ), ResourceTextContentType::ChatThread => process_file_data::( resource_data, @@ -506,11 +522,12 @@ where T: serde::de::DeserializeOwned, { // Check if this is a markdown file for supported resource types - if is_markdown_resource_type(&resource.resource.resource_type) && - is_markdown_file(&resource.resource.resource_path) { + if is_markdown_resource_type(&resource.resource.resource_type) + && is_markdown_file(&resource.resource.resource_path) + { // Parse markdown with frontmatter let (content, frontmatter) = parse_markdown_with_frontmatter(data)?; - + // Try to deserialize the frontmatter into our expected type match serde_yaml::from_value::(frontmatter) { Ok(parsed_data) => { From f093af15fa90c26e5f9a8be0ae8b2b80ca7fcfca Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Thu, 13 Nov 2025 17:36:06 +0100 Subject: [PATCH 02/24] chore: use list resources by tags paginated APIs in sffs and resource manager services Signed-off-by: Aavash Shrestha --- .../src/lib/resources/resources.svelte.ts | 89 +++++++++++-------- packages/services/src/lib/sffs.ts | 77 ++++++++++++++-- packages/types/src/resources.types.ts | 11 +++ 3 files changed, 134 insertions(+), 43 deletions(-) diff --git a/packages/services/src/lib/resources/resources.svelte.ts b/packages/services/src/lib/resources/resources.svelte.ts index 10b0447d0..331096cdc 100644 --- a/packages/services/src/lib/resources/resources.svelte.ts +++ b/packages/services/src/lib/resources/resources.svelte.ts @@ -27,6 +27,8 @@ import { type AiSFFSQueryResponse, type SFFSResourceMetadata, type SFFSResourceTag, + type SFFSPaginationParams, + type SFFSPaginatedResult, ResourceTypes, type SFFSResource, type ResourceDataLink, @@ -967,23 +969,62 @@ export class ResourceManager extends EventEmitterBase(resources) } - async listResourceIDsByTags(tags: SFFSResourceTag[], excludeWithinSpaces: boolean = false) { - const results = await this.sffs.listResourceIDsByTags(tags, excludeWithinSpaces) + async listResourceIDsByTags( + tags: SFFSResourceTag[], + excludeWithinSpaces: boolean = false, + paginationParams: SFFSPaginationParams + ) { + const results = await this.sffs.listResourceIDsByTags( + tags, + excludeWithinSpaces, + paginationParams + ) return results } + // TODO: why leak SFFS types and still have this abstractions? async listResourcesByTags( tags: SFFSResourceTag[], + paginationParams: SFFSPaginationParams, opts: { includeAnnotations?: boolean; excludeWithinSpaces?: boolean } = {} ) { - const resourceIds = await this.sffs.listResourceIDsByTags( + const result = await this.sffs.listResourceIDsByTags( + tags, + opts?.excludeWithinSpaces ?? false, + paginationParams + ) + // TODO: is this the right behavior? + if (!result) { + return [] + } + this.log.debug('found resource ids', result.items) + const resources = (await Promise.all( + result.items.map((id) => this.findOrGetResourceObject(id, opts)) + )) as Resource[] + return { + items: resources.filter((r) => r !== null), + next_cursor: result.next_cursor, + has_more: result.has_more + } as SFFSPaginatedResult + } + + async listAllResourcesByTags( + tags: SFFSResourceTag[], + opts: { includeAnnotations?: boolean; excludeWithinSpaces?: boolean } = {} + ) { + const result = await this.sffs.listAllResourceIDsByTags( tags, opts?.excludeWithinSpaces ?? false ) - this.log.debug('found resource ids', resourceIds) - return (await Promise.all( - resourceIds.map((id) => this.findOrGetResourceObject(id, opts)) + // TODO: is this the right behavior? + if (!result) { + return [] + } + this.log.debug('found resource ids', result) + const resources = (await Promise.all( + result.map((id) => this.findOrGetResourceObject(id, opts)) )) as Resource[] + return resources.filter((r) => r !== null) } async listAllResourcesAndSpaces(tags: SFFSResourceTag[]) { @@ -1047,30 +1088,6 @@ export class ResourceManager extends EventEmitterBase - // ({ - // id: item.resource.id, - // engine: item.engine, - // cardIds: item.card_ids, - // resource: this.findOrCreateResourceObject(item.resource) - // }) as ResourceSearchResultItem - // ) - - // return results - // } - - async getResourceAnnotations() { - const resources = await this.listResourcesByTags([ - SearchResourceTags.ResourceType(ResourceTypes.ANNOTATION), - SearchResourceTags.Deleted(false) - ]) - - return resources as ResourceAnnotation[] - } - async getResourcesFromSourceURL(url: string, tags?: SFFSResourceTag[]) { const surfUrlMatch = url.match(/surf:\/\/resource\/([^\/]+)/) if (surfUrlMatch) { @@ -1083,7 +1100,7 @@ export class ResourceManager extends EventEmitterBase + ({ + id: '', + resource_id: '', + tag_name: tag.name, + tag_value: tag.value, + op: tag.op ?? 'eq' + }) as SFFSRawResourceTag + ) + ) + } + + async listAllResourceIDsByTags( + tags: SFFSResourceTag[], + excludeWithinSpaces: boolean = false + ): Promise { + this.log.debug('listing all resources by tags', tags, excludeWithinSpaces) + + const tagsData = this.serializeTagsData(tags) + + const allResults: string[] = [] + let hasMore = true + let cursor: string | null = null + + while (hasMore) { + const paginationParams: SFFSPaginationParams = { + limit: 100, + ...(cursor && { cursor }) + } + const paginationData = JSON.stringify(paginationParams) + + let raw: string + if (excludeWithinSpaces) { + raw = await this.backend.js__store_list_resources_by_tags_no_space(tagsData, paginationData) + } else { + raw = await this.backend.js__store_list_resources_by_tags(tagsData, paginationData) + } + + const parsed = this.parseData>(raw) + if (!parsed) { + throw new Error( + 'failed to parse result of list resources by tags, unexpected data from backend' + ) + } + + allResults.push(...parsed.items) + hasMore = parsed.has_more + cursor = parsed.next_cursor + } + + this.log.debug(`fetched ${allResults.length} total resources by tags`) + return allResults + } + + async listResourceIDsByTags( + tags: SFFSResourceTag[], + excludeWithinSpaces: boolean = false, + paginationParams: SFFSPaginationParams + ) { this.log.debug('listing resources by tags', tags, excludeWithinSpaces) const tagsData = JSON.stringify( tags.map( @@ -453,16 +516,16 @@ export class SFFS { }) as SFFSRawResourceTag ) ) + const paginationData = JSON.stringify(paginationParams) let raw: string if (excludeWithinSpaces) { - raw = await this.backend.js__store_list_resources_by_tags_no_space(tagsData) + raw = await this.backend.js__store_list_resources_by_tags_no_space(tagsData, paginationData) } else { - raw = await this.backend.js__store_list_resources_by_tags(tagsData) + raw = await this.backend.js__store_list_resources_by_tags(tagsData, paginationData) } - - const parsed = this.parseData<{ items: string[]; total: number }>(raw) - return parsed?.items ?? [] + const parsed = this.parseData>(raw) + return parsed } async listAllResourcesAndSpaces(tags: SFFSResourceTag[]) { diff --git a/packages/types/src/resources.types.ts b/packages/types/src/resources.types.ts index 64b71f240..28aa280c3 100644 --- a/packages/types/src/resources.types.ts +++ b/packages/types/src/resources.types.ts @@ -28,6 +28,17 @@ export interface SFFSResourceTag { op?: 'eq' | 'ne' | 'prefix' | 'suffix' | 'notexists' | 'neprefix' | 'nesuffix' } +export interface SFFSPaginationParams { + limit: number + cursor?: string +} + +export interface SFFSPaginatedResult { + items: T[] + next_cursor: string | null + has_more: boolean +} + export enum ResourceTagsBuiltInKeys { SAVED_WITH_ACTION = 'savedWithAction', TYPE = 'type', From 4fa894e6bd0620a49242dfa0b41a18bd638acba5 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Fri, 14 Nov 2025 15:10:06 +0100 Subject: [PATCH 03/24] chore: use pagination load APIs in SurfLoader component Signed-off-by: Aavash Shrestha --- .../lib/components/Utils/SurfLoader.svelte | 206 +++++++++++------- 1 file changed, 128 insertions(+), 78 deletions(-) diff --git a/packages/ui/src/lib/components/Utils/SurfLoader.svelte b/packages/ui/src/lib/components/Utils/SurfLoader.svelte index 35efbf210..388cef79d 100644 --- a/packages/ui/src/lib/components/Utils/SurfLoader.svelte +++ b/packages/ui/src/lib/components/Utils/SurfLoader.svelte @@ -2,30 +2,53 @@ import { onMount, type Snippet } from 'svelte' import { type Resource } from '@deta/services/resources' import { useNotebookManager, type Notebook } from '@deta/services/notebooks' - import { ResourceTagsBuiltInKeys, type Option, type SFFSResourceTag, type SFFSSearchParameters } from '@deta/types' - import { type ResourceSearchResult, useResourceManager} from '@deta/services/resources' + import { + ResourceTagsBuiltInKeys, + type Option, + type SFFSResourceTag, + type SFFSSearchParameters, + type SFFSPaginatedResult + } from '@deta/types' + import { type ResourceSearchResult, useResourceManager } from '@deta/services/resources' import { SearchResourceTags, useCancelableDebounce, useThrottle } from '@deta/utils' import { NotebookManagerEvents } from '@deta/services/notebooks' interface Search { query: string - tags?: SFFSResourceTag[], + tags?: SFFSResourceTag[] parameters?: SFFSSearchParameters } + interface PaginationState { + cursor: string | null + hasMore: boolean + isLoadingMore: boolean + } + let { tags, - // Only used in contents, not search.. our apis are bonked excludeWithinSpaces = false, search, + pageSize = 50, children, loading, - error, + error }: { tags: SFFSResourceTag[] excludeWithinSpaces?: boolean search?: Search - children: Snippet<[Notebook]> + pageSize?: number + children: Snippet< + [ + { + resources: Resource[] + searchResults: Option + searching: boolean + pagination: PaginationState + loadMore: () => Promise + } + ] + > loading?: Snippet error?: Snippet<[unknown]> } = $props() @@ -33,70 +56,59 @@ const resourceManager = useResourceManager() const notebookManager = useNotebookManager() - // TODO: Reuse or dispose - //const getNotebook = (id: string) => { - // return new Promise<[Notebook, Option]>((res, _) => { - // notebookManager.getNotebook(notebookId) - // .then((notebook: Notebook) => { - // if (fetchContents) notebook.fetchContents() - // res([notebook, undefined]) - // }) - // }) - //} - //const getNotebookSearch = (search: Search) => { - // return new Promise<[Notebook, Option]>((res, _) => { - // Promise.all([ - // getNotebook(notebookId), - // useResourceManager().searchResources(search.query, search.tags ?? [], { - // ...search.parameters, - // spaceId: notebookId - // }) - // ]).then(([notebook, searchResults]) => res([notebook, searchResults])) - // }) - //} - //const notebookLoader = $derived(search && search.query ? getNotebookSearch(search) : getNotebook(notebookId)) - - // NOTE: This makes them reactive, so that in the children snippets, it doesn't - // re-render the entire snippet but only the items further down the chain if the - // notebook or the search results change! let resources: Resource[] = $state([]) - let searchResults: Option = $state([]) + let searchResults: Option = $state(undefined) let searching: boolean = $state(false) let isLoading: boolean = $state(false) + let pagination: PaginationState = $state({ + cursor: null, + hasMore: false, + isLoadingMore: false + }) + const { execute: runQuery, cancel: cancelQuery } = useCancelableDebounce((search: Search) => { try { searching = true isLoading = true - resourceManager.searchResources(search.query, [ - ...SearchResourceTags.NonHiddenDefaultTags({ - excludeAnnotations: true - }), - SearchResourceTags.NotExists(ResourceTagsBuiltInKeys.EMPTY_RESOURCE), - ...(search.tags ?? []) - ], { - ...search.parameters, - spaceId: undefined - }).then(results => { + resourceManager + .searchResources( + search.query, + [ + ...SearchResourceTags.NonHiddenDefaultTags({ + excludeAnnotations: true + }), + SearchResourceTags.NotExists(ResourceTagsBuiltInKeys.EMPTY_RESOURCE), + ...(search.tags ?? []) + ], + { + ...search.parameters, + spaceId: undefined + } + ) + .then((results) => { searchResults = results.resources - .sort((a, b) => new Date(b.resource.updatedAt).getTime() - new Date(a.resource.updatedAt).getTime()) - .map(e => e.resource) + .sort( + (a, b) => + new Date(b.resource.updatedAt).getTime() - new Date(a.resource.updatedAt).getTime() + ) + .map((e) => e.resource) .filter((e) => { if (excludeWithinSpaces && resources !== undefined) { - return resources.find(item => item.id === e.id) + return resources.find((item) => item.id === e.id) } return e }) searching = false }) - } catch(e) { + } catch (e) { console.error(e) - }finally { + } finally { searching = false isLoading = false } }, 250) - + $effect(() => { if (search && search.query) { runQuery(search) @@ -107,26 +119,68 @@ } }) - const load = useThrottle(async () => { - try { - isLoading = true - - const result = await resourceManager.listResourcesByTags([ + const loadPage = async (cursor?: string) => { + const result: SFFSPaginatedResult = await resourceManager.listResourcesByTags( + [ ...SearchResourceTags.NonHiddenDefaultTags({ excludeAnnotations: true }), SearchResourceTags.NotExists(ResourceTagsBuiltInKeys.EMPTY_RESOURCE), ...(tags ?? []) - ], { includeAnnotations: false, excludeWithinSpaces }) + ], + { + limit: pageSize, + cursor + }, + { + includeAnnotations: false, + excludeWithinSpaces + } + ) + + return result + } + + const load = useThrottle(async () => { + try { + isLoading = true - resources = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + // reset pagination state on fresh load + pagination.cursor = null + pagination.hasMore = false + + const result = await loadPage() + resources = result + pagination.cursor = result.next_cursor + pagination.hasMore = result.has_more } catch (error) { - console.error('Error loading notebook', error) + console.error('Error loading resources', error) } finally { isLoading = false } }, 250) + const loadMore = async () => { + if (!pagination.hasMore || pagination.isLoadingMore || !pagination.cursor) { + return + } + + try { + pagination.isLoadingMore = true + + const result = await loadPage(pagination.cursor) + const newResources = result.items + resources = [...resources, ...newResources] + + pagination.cursor = result.next_cursor + pagination.hasMore = result.has_more + } catch (error) { + console.error('Error loading more resources', error) + } finally { + pagination.isLoadingMore = false + } + } + const init = async () => { isLoading = true @@ -136,41 +190,37 @@ runQuery(search) } } - + init() onMount(() => { - const unsubs = [ - //notebookManager.on(NotebookManagerEvents.CreatedResource, () => load()), + const unsubs = [ notebookManager.on(NotebookManagerEvents.DeletedResource, (resourceId: string) => { - resources = resources.filter(e => resourceId != e.id) - if (searchResults) searchResults = searchResults.filter(e => resourceId != e.id) + resources = resources.filter((e) => resourceId != e.id) + if (searchResults) searchResults = searchResults.filter((e) => resourceId != e.id) }), notebookManager.on(NotebookManagerEvents.RemovedResources, (_, resourceIds: string[]) => { - resources = resources.filter(e => !resourceIds.includes(e.id)) - if (searchResults) searchResults = searchResults.filter(e => !resourceIds.includes(e.id)) + resources = resources.filter((e) => !resourceIds.includes(e.id)) + if (searchResults) searchResults = searchResults.filter((e) => !resourceIds.includes(e.id)) }) ] + if (excludeWithinSpaces) { - unsubs.push( - notebookManager.on(NotebookManagerEvents.AddedResources, () => load()), - ) + unsubs.push(notebookManager.on(NotebookManagerEvents.AddedResources, () => load())) } - return () => unsubs.forEach(f => f()) + + return () => unsubs.forEach((f) => f()) }) - {#if isLoading} {@render loading?.()} {:else} - {@render children?.([resources, searchResults, searching])} + {@render children?.({ + resources, + searchResults, + searching, + pagination, + loadMore + })} {/if} - From 40cdc016905c8326de956e563464f3ea59dd52d8 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Mon, 10 Nov 2025 14:53:13 +0100 Subject: [PATCH 04/24] feat: search input in notebook contents view Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 188 +++++++----------- .../components/Teletype/TeletypeCore.svelte | 4 +- 2 files changed, 76 insertions(+), 116 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 489a6d3bd..151d53f6c 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -1,5 +1,5 @@ {#snippet notesList(visibleItems, allItems)} {#if allItems.length <= 0} - {#if (notebookId ? $ttyQuery : searchQuery).length > 0} + {#if searchQuery.length > 0}
-

Nothing found for "{notebookId ? $ttyQuery : searchQuery}"

+

Nothing found for "{searchQuery}"

{:else}
@@ -209,22 +217,14 @@ {/snippet} {/each} - - {#if allItems.length > visibleItems.length} -
(showAll = !showAll)}> - -
- {/if} {/if} {/snippet} {#snippet sourcesList(visibleItems, allItems)} {#if allItems.length <= 0} - {#if (notebookId ? $ttyQuery : searchQuery).length > 0} + {#if searchQuery.length > 0}
-

Nothing found for "{notebookId ? $ttyQuery : searchQuery}"

+

Nothing found for "{searchQuery}"

{:else}
@@ -308,74 +308,56 @@
(showAllNotes = false)} tabs={[ ...conditionalArrayItem(notebookId === undefined, { id: 'notebooks', label: 'Notebooks', icon: 'notebook' }), - { - id: 'notes', - label: 'Notes', - icon: 'note' - }, - { - id: 'sources', - label: 'Media', - icon: 'link' - } + ...conditionalArrayItem(notebookId !== undefined, [ + { + id: 'notes', + label: 'Notes', + icon: 'note' + }, + { + id: 'sources', + label: 'Media', + icon: 'link' + } + ]) ]} /> - - - {#if activeTab === 'notes'} - + {/if} + + - {:else if activeTab === 'sources'} - - {/if} +
{#if activeTab === 'notebooks'} {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)}
- {#if !searchQuery} -
{ - try { - const notebook = await notebookManager.createNotebook( - { - name: 'Untitled Notebook' - }, - true - ) - isNewNotebook = notebook - } catch (e) { - console.error('Failed to create notebook', e) - } - }} - > -
- - -
-
- {/if} - {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())}
{/if} - {#each notebooksList.slice(0, showAll ? Infinity : notebooksList.filter((e) => e.data.pinned).length) as notebook, i (notebook.id + i)} + {#each notebooksList as notebook, i (notebook.id + i)}
{/each}
- - {#if notebooksList.length > notebooksList.slice(0, showAll ? Infinity : notebooksList.filter((e) => e.data.pinned).length).length} -
(showAll = !showAll)}> - -
- {/if} {/if} {:else if activeTab === 'notes'}
    @@ -481,10 +455,7 @@ }} > {#snippet children([resources, searchResult, searching])} - {@render notesList( - (searchResult ?? resources).slice(0, showAll ? Infinity : 6), - resources - )} + {@render notesList(searchResult ?? resources, resources)} {/snippet} {#snippet loading()} @@ -499,7 +470,7 @@ excludeWithinSpaces tags={[SearchResourceTags.ResourceType(ResourceTypes.DOCUMENT_SPACE_NOTE, 'eq')]} search={{ - query: $ttyQuery, + query: searchQuery, tags: [SearchResourceTags.ResourceType(ResourceTypes.DOCUMENT_SPACE_NOTE, 'eq')], parameters: { semanticSearch: false @@ -507,10 +478,7 @@ }} > {#snippet children([resources, searchResult, searching])} - {@render notesList( - (searchResult ?? resources).slice(0, showAll ? Infinity : 6), - resources - )} + {@render notesList(searchResult ?? resources, resources)} {/snippet} {#snippet loading()} @@ -524,7 +492,7 @@ {#snippet children([resources, searchResult, searching])} - {@render sourcesList( - (searchResult ?? resources).slice( - 0, - searchResult ? Infinity : showAll ? Infinity : resourceRenderCnt - ), - resources - )} + {@render sourcesList(searchResult ?? resources, resources)} {/snippet} {#snippet loading()} @@ -585,7 +547,7 @@ excludeWithinSpaces tags={[SearchResourceTags.ResourceType(ResourceTypes.DOCUMENT_SPACE_NOTE, 'ne')]} search={{ - query: $ttyQuery, + query: searchQuery, tags: [SearchResourceTags.ResourceType(ResourceTypes.DOCUMENT_SPACE_NOTE, 'ne')], parameters: { semanticSearch: false @@ -593,13 +555,7 @@ }} > {#snippet children([resources, searchResult, searching])} - {@render sourcesList( - (searchResult ?? resources).slice( - 0, - searchResult ? Infinity : showAll ? Infinity : resourceRenderCnt - ), - resources - )} + {@render sourcesList(searchResult ?? resources, resources)} {/snippet} {#snippet loading()} @@ -613,7 +569,7 @@ Date: Mon, 10 Nov 2025 16:55:21 +0100 Subject: [PATCH 05/24] feat: category views in notebook contents Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 826 +++++++++--------- .../components/Resources/SourceCard.svelte | 27 +- .../src/lib/components/Tabs/SimpleTabs.svelte | 24 +- 3 files changed, 465 insertions(+), 412 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 151d53f6c..d878f646a 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -9,7 +9,6 @@ openDialog, ResourceLoader, SearchInput, - SimpleTabs, SourceCard, SurfLoader } from '@deta/ui' @@ -25,21 +24,17 @@ import { useResourceManager, Resource, getResourceCtxItems } from '@deta/services/resources' import { useMessagePortClient } from '@deta/services/messagePort' import { promptForFilesAndTurnIntoResources } from '@deta/services' - import { tick } from 'svelte' let { notebookId }: { notebookId?: string } = $props() let isCustomizingNotebook = $state(undefined) as Notebook | undefined | null let isNewNotebook = $state(undefined) as Notebook | undefined | null - let activeTab = $state<'notebooks' | 'notes' | 'sources'>( - notebookId === undefined ? 'notebooks' : 'notes' - ) let searchQuery = $state('') - let searchCollapsed = $state(true) + let categoryScrollContainer = $state() + let collapsedCategories = $state>(new Set()) let resourceRenderCnt = $state(20) - // TODO: Put this into lazy scroll component, no need for rawdogging crude js const handleMediaWheel = useThrottle(() => { resourceRenderCnt += 4 }, 5) @@ -123,18 +118,8 @@ notebookManager.removeResourcesFromNotebook(notebookId, [resourceId], true) } - const handleSearchCollapsedToggle = () => { - searchCollapsed = !searchCollapsed - } - const handleUploadFiles = async () => { await promptForFilesAndTurnIntoResources(resourceManager, notebookId) - - if (!notebookId || notebookId === 'drafts') { - activeTab = 'notes' - await tick() - activeTab = 'sources' - } } const handleOpenAsFile = (resourceId: string) => { @@ -182,6 +167,63 @@ return searchResults.filter((e) => e.resource_type !== ResourceTypes.DOCUMENT_SPACE_NOTE) } else return resources.filter((e) => e.resource_type !== ResourceTypes.DOCUMENT_SPACE_NOTE) } + + const categories = $derived([ + ...conditionalArrayItem(notebookId === undefined, { + id: 'notebooks', + label: 'Notebooks', + icon: 'notebook' + }), + ...conditionalArrayItem(notebookId !== undefined, [ + { + id: 'notes', + label: 'Notes', + icon: 'note' + }, + { + id: 'sources', + label: 'Media', + icon: 'link' + } + ]) + ]) + + const getAddButtonAction = (categoryId: string) => { + if (categoryId === 'notebooks') return handleCreateNotebook + if (categoryId === 'notes') return handleCreateNote + if (categoryId === 'sources') return handleUploadFiles + } + + const getAddButtonTooltip = (categoryId: string) => { + if (categoryId === 'notebooks') return 'Create Notebook' + if (categoryId === 'notes') return 'Create Note' + if (categoryId === 'sources') return 'Import Media' + } + + const toggleCategoryCollapse = (categoryId: string) => { + const newCollapsed = new Set(collapsedCategories) + if (newCollapsed.has(categoryId)) { + newCollapsed.delete(categoryId) + } else { + newCollapsed.add(categoryId) + } + collapsedCategories = newCollapsed + } + + const resetCollapsedCategories = () => { + collapsedCategories = new Set() + } + + const isCategoryCollapsed = (categoryId: string) => collapsedCategories.has(categoryId) + + $effect(() => { + if (searchQuery.length > 0) { + if (categoryScrollContainer) { + categoryScrollContainer.scrollTo({ left: 0, behavior: 'smooth' }) + } + resetCollapsedCategories() + } + }) {#snippet notesList(visibleItems, allItems)} @@ -204,19 +246,19 @@ Jump start a new note by asking Surf's AI something in the input box above or create a blank note using the button.

    - -
{/if} {:else} - {#each visibleItems as resource, i (typeof resource === 'string' ? resource : resource.id + i)} - - {#snippet children(resource: Resource)} - - {/snippet} - - {/each} +
+ {#each visibleItems as resource, i (typeof resource === 'string' ? resource : resource.id + i)} + + {#snippet children(resource: Resource)} + + {/snippet} + + {/each} +
{/if} {/snippet} @@ -240,18 +282,6 @@ Save web pages using the "Save" button while browsing, import local files or add existing media from other notebooks by right-clicking them.

- - - - - -
{/if} @@ -261,7 +291,8 @@ {#snippet children(resource: Resource)} {/each} - {#if resourceRenderCnt < allItems.length} + {#if resourceRenderCnt < allItems.length && !searchQuery}
Scroll to load more
@@ -305,353 +336,397 @@ /> {/if} -
- +
+
+

Your Library

+ +
+ +
+
+ {#each categories as category} +
+
+ + +
-
- {#if searchCollapsed} - - {/if} - - -
-
- -{#if activeTab === 'notebooks'} - {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)} -
- {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())} -
{ - handleNotebookClick('drafts', event) - }} - > - {}} - /> -
- {/if} - - {#each notebooksList as notebook, i (notebook.id + i)} -
- handleNotebookClick(notebook.id, e)} - onpin={() => handlePinNotebook(notebook.id)} - onunpin={() => handleUnPinNotebook(notebook.id)} - {@attach contextMenu({ - canOpen: true, - items: [ - !notebook.data.pinned - ? { - type: 'action', - text: 'Add to Favorites', - icon: 'heart', - action: () => handlePinNotebook(notebook.id) - } - : { - type: 'action', - text: 'Remove from Favorites', - icon: 'heart.off', - action: () => handleUnPinNotebook(notebook.id) - }, - /*{ - type: 'action', - text: 'Rename', - icon: 'edit', - action: () => (isRenamingNotebook = notebook.id) - },*/ - { - type: 'action', - text: 'Customize', - icon: 'edit', - action: () => (isCustomizingNotebook = notebook) - }, - - { - type: 'action', - kind: 'danger', - text: 'Delete', - icon: 'trash', - action: () => handleDeleteNotebook(notebook) - } - ] - })} - /> + {#if !isCategoryCollapsed(category.id)} +
+ {#if category.id === 'notebooks'} + {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)} +
+ {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())} +
{ + handleNotebookClick('drafts', event) + }} + > + {}} + /> +
+ {/if} + + {#each notebooksList as notebook, i (notebook.id + i)} +
+ handleNotebookClick(notebook.id, e)} + onpin={() => handlePinNotebook(notebook.id)} + onunpin={() => handleUnPinNotebook(notebook.id)} + {@attach contextMenu({ + canOpen: true, + items: [ + !notebook.data.pinned + ? { + type: 'action', + text: 'Add to Favorites', + icon: 'heart', + action: () => handlePinNotebook(notebook.id) + } + : { + type: 'action', + text: 'Remove from Favorites', + icon: 'heart.off', + action: () => handleUnPinNotebook(notebook.id) + }, + { + type: 'action', + text: 'Customize', + icon: 'edit', + action: () => (isCustomizingNotebook = notebook) + }, + { + type: 'action', + kind: 'danger', + text: 'Delete', + icon: 'trash', + action: () => handleDeleteNotebook(notebook) + } + ] + })} + /> +
+ {/each} +
+ {/if} + {:else if category.id === 'notes'} +
    + {#if !notebookId} + + {#snippet children([resources, searchResult, searching])} + {@render notesList(searchResult ?? resources, resources)} + {/snippet} + + {#snippet loading()} +
    + +

    Loading…

    +
    + {/snippet} +
    + {:else if notebookId === 'drafts'} + + {#snippet children([resources, searchResult, searching])} + {@render notesList(searchResult ?? resources, resources)} + {/snippet} + + {#snippet loading()} +
    + +

    Loading…

    +
    + {/snippet} +
    + {:else} + + {#snippet children([notebook, searchResult, searching])} + {@render notesList( + filterNoteResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ), + filterNoteResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ) + )} + {/snippet} + + {#snippet loading()} +
    + +

    Loading…

    +
    + {/snippet} +
    + {/if} +
+ {:else if category.id === 'sources'} + {#if !notebookId} + + {#snippet children([resources, searchResult, searching])} + {@render sourcesList(searchResult ?? resources, resources)} + {/snippet} + + {#snippet loading()} +
+ +

Loading…

+
+ {/snippet} +
+ {:else if notebookId === 'drafts'} + + {#snippet children([resources, searchResult, searching])} + {@render sourcesList(searchResult ?? resources, resources)} + {/snippet} + + {#snippet loading()} +
+ +

Loading…

+
+ {/snippet} +
+ {:else} + + {#snippet children([notebook, searchResult, searching])} + {@render sourcesList( + filterOtherResources(notebook?.contents ?? [], searchResult) + .slice(0, resourceRenderCnt) + .map((e) => e.entry_id), + filterOtherResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ) + )} + {/snippet} + + {#snippet loading()} +
+ +

Loading…

+
+ {/snippet} +
+ {/if} + {/if} +
+ {/if}
{/each}
- {/if} -{:else if activeTab === 'notes'} -
    - - - {#if !notebookId} - - {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} -
    - -

    Loading…

    -
    - {/snippet} -
    - {:else if notebookId === 'drafts'} - - {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} -
    - -

    Loading…

    -
    - {/snippet} -
    - {:else} - - {#snippet children([notebook, searchResult, searching])} - {@render notesList( - filterNoteResources(notebook?.contents ?? [], searchResult).map((e) => e.entry_id), - filterNoteResources(notebook?.contents ?? [], searchResult).map((e) => e.entry_id) - )} - {/snippet} - - {#snippet loading()} -
    - -

    Loading…

    -
    - {/snippet} -
    - {/if} -
-{:else if activeTab === 'sources'} - - - {#if !notebookId} - - {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} -
- -

Loading…

-
- {/snippet} -
- {:else if notebookId === 'drafts'} - - {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} -
- -

Loading…

-
- {/snippet} -
- {:else} - - {#snippet children([notebook, searchResult, searching])} - {@render sourcesList( - filterOtherResources(notebook?.contents ?? [], searchResult) - .slice(0, resourceRenderCnt) - .map((e) => e.entry_id), - filterOtherResources(notebook?.contents ?? [], searchResult).map((e) => e.entry_id) - )} - {/snippet} - - {#snippet loading()} -
- -

Loading…

-
- {/snippet} -
- {/if} -{/if} + + diff --git a/packages/ui/src/lib/components/Resources/SourceCard.svelte b/packages/ui/src/lib/components/Resources/SourceCard.svelte index f1276841f..b7a4eb953 100644 --- a/packages/ui/src/lib/components/Resources/SourceCard.svelte +++ b/packages/ui/src/lib/components/Resources/SourceCard.svelte @@ -45,6 +45,7 @@ : null ) let imageError = $state(false) + let faviconError = $state(false) const handleClick = (e: MouseEvent) => { onclick?.(e) @@ -59,6 +60,10 @@ imageError = true } + const handleFaviconError = () => { + faviconError = true + } + onMount(async () => { getResourcePreview(resource, {}).then((v) => (data = v)) }) @@ -138,17 +143,24 @@
- {:else} -
- -
+ {:else if faviconUrl && !faviconError} + {data?.title e.preventDefault()} + onerror={handleFaviconError} + /> {/if} + @@ -216,11 +228,12 @@ align-items: center; perspective: 200px; max-width: var(--max-width, inherit); + padding: 0.5em 0.5em; .card { flex-shrink: 0; --padding: 4px; - --corner-radius: 16px; + --corner-radius: 6px; padding: var(--padding); background: light-dark(#fff, #1a2438); @@ -228,8 +241,8 @@ border-radius: var(--corner-radius); width: var(--width, 100%); + height: var(--height, auto); aspect-ratio: 3.1 / 4; - //height: var(--height, auto); content-visibility: auto; box-shadow: light-dark(rgba(99, 99, 99, 0.15), rgba(0, 0, 0, 0.3)) 0px 2px 8px 0px; diff --git a/packages/ui/src/lib/components/Tabs/SimpleTabs.svelte b/packages/ui/src/lib/components/Tabs/SimpleTabs.svelte index 1053c482e..768342118 100644 --- a/packages/ui/src/lib/components/Tabs/SimpleTabs.svelte +++ b/packages/ui/src/lib/components/Tabs/SimpleTabs.svelte @@ -1,16 +1,20 @@
@@ -27,7 +31,7 @@ }} > {#if tab.icon} - + {/if} {tab.label}
@@ -61,7 +65,7 @@ &:hover:not(.disabled):not(.active) { background: light-dark(rgba(0, 0, 0, 0.075), rgba(255, 255, 255, 0.1)); - cursor: pointer; + cursor: pointer; } &.active { From 7913906b3e45c2a96b7bcf2d9ba26fd7d28075fa Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Mon, 10 Nov 2025 17:34:31 +0100 Subject: [PATCH 06/24] chore: refactor for loading and no results snippet and only show add button on non collapsed Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 101 ++++++++---------- 1 file changed, 46 insertions(+), 55 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index d878f646a..9d18bde80 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -32,7 +32,7 @@ let searchQuery = $state('') let categoryScrollContainer = $state() - let collapsedCategories = $state>(new Set()) + let collapsedCategories = $state>(new Set(['notes', 'sources'])) let resourceRenderCnt = $state(20) const handleMediaWheel = useThrottle(() => { @@ -174,18 +174,16 @@ label: 'Notebooks', icon: 'notebook' }), - ...conditionalArrayItem(notebookId !== undefined, [ - { - id: 'notes', - label: 'Notes', - icon: 'note' - }, - { - id: 'sources', - label: 'Media', - icon: 'link' - } - ]) + { + id: 'notes', + label: 'Notes', + icon: 'note' + }, + { + id: 'sources', + label: 'Media', + icon: 'link' + } ]) const getAddButtonAction = (categoryId: string) => { @@ -226,12 +224,23 @@ }) +{#snippet loadingSnippet()} +
+ +

Loading…

+
+{/snippet} + +{#snippet noResultsSnippet()} +
+

Nothing found for "{searchQuery}"

+
+{/snippet} + {#snippet notesList(visibleItems, allItems)} {#if allItems.length <= 0} {#if searchQuery.length > 0} -
-

Nothing found for "{searchQuery}"

-
+ {@render noResultsSnippet()} {:else}
@@ -265,9 +274,7 @@ {#snippet sourcesList(visibleItems, allItems)} {#if allItems.length <= 0} {#if searchQuery.length > 0} -
-

Nothing found for "{searchQuery}"

-
+ {@render noResultsSnippet()} {:else}
@@ -355,18 +362,20 @@ style="margin-left: auto; font-size: 0.8rem; opacity: 0.5;" /> - + {#if !isCategoryCollapsed(category.id)} + + {/if}
{#if !isCategoryCollapsed(category.id)} -
+
{#if category.id === 'notebooks'} {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)}
@@ -464,10 +473,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {:else if notebookId === 'drafts'} @@ -491,10 +497,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {:else} @@ -520,10 +523,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {/if} @@ -549,10 +549,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {:else if notebookId === 'drafts'} @@ -576,10 +573,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {:else} @@ -605,10 +599,7 @@ {/snippet} {#snippet loading()} -
- -

Loading…

-
+ {@render loadingSnippet()} {/snippet} {/if} @@ -712,14 +703,14 @@ } .category-content { - max-height: 400px; + max-height: 360px; overflow-y: auto; } .notebook-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(11.25ch, 1fr)); - gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(11.3ch, 1fr)); + gap: 0.5rem; } .sources-grid { From aaf4f0b7088c08d979ef2a8f980d48c8f74a4663 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Wed, 12 Nov 2025 16:11:08 +0100 Subject: [PATCH 07/24] chore: responsive notebook main layout and content max heights Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 10 +++- .../Resource/layouts/NotebookLayout.svelte | 51 ++++++++----------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 9d18bde80..ec9af0c8a 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -703,8 +703,16 @@ } .category-content { - max-height: 360px; + max-height: min(360px, 40vh); overflow-y: auto; + + @media screen and (min-width: 1440px) { + max-height: 50vh; + } + + @media screen and (min-width: 1920px) { + max-height: 60vh; + } } .notebook-grid { diff --git a/app/src/renderer/Resource/layouts/NotebookLayout.svelte b/app/src/renderer/Resource/layouts/NotebookLayout.svelte index 9fcf232b1..569e41d79 100644 --- a/app/src/renderer/Resource/layouts/NotebookLayout.svelte +++ b/app/src/renderer/Resource/layouts/NotebookLayout.svelte @@ -12,44 +12,35 @@ transition: transform 123ms ease-out; } } + .notebook { position: relative; overflow-x: hidden; - - //.bg { - // content: ''; - // position: absolute; - // inset: -4px; - // background: - // linear-gradient(rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 1)), - // url('https://i.imgur.com/7XbyivJ.png'); - // background-repeat: no-repeat; - // background-size: cover; - // background-position: 50% 30%; - // //background: #fff; - // z-index: -1; - // filter: blur(2px); - // pointer-events: none; - //} - height: 100vh; width: 100%; display: flex; flex-direction: column; - padding-inline: 1.5rem; - padding-block: 4.5rem; + // default for laptops (13-15") + padding-inline: 2rem; + padding-block: 2rem; + + // larger laptops and small external monitors (15-24") + @media screen and (min-width: 1440px) { + padding-inline: 3rem; + padding-block: 3rem; + } + + // large external monitors (27"+) + @media screen and (min-width: 1920px) { + padding-inline: 4rem; + padding-block: 4rem; + } - //&::before { - // content: ''; - // position: absolute; - // top: 0; - // left: 0; - // right: 0; - // height: 2rem; - // z-index: 0; - // pointer-events: none; - // background: linear-gradient(to bottom, var(--page-gradient-color), transparent); - //} + // ultra-wide or very large displays + @media screen and (min-width: 2560px) { + padding-inline: 5rem; + padding-block: 5rem; + } } From df47fc0ede61d2a9f43b18d92ea59c94ff838fda Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Wed, 12 Nov 2025 16:50:22 +0100 Subject: [PATCH 08/24] chore: also allow library to be collapsed Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 541 ++++++++++-------- 1 file changed, 311 insertions(+), 230 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index ec9af0c8a..69ee5760e 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -33,6 +33,7 @@ let searchQuery = $state('') let categoryScrollContainer = $state() let collapsedCategories = $state>(new Set(['notes', 'sources'])) + let isLibraryCollapsed = $state(true) let resourceRenderCnt = $state(20) const handleMediaWheel = useThrottle(() => { @@ -208,6 +209,10 @@ collapsedCategories = newCollapsed } + const toggleLibraryCollapse = () => { + isLibraryCollapsed = !isLibraryCollapsed + } + const resetCollapsedCategories = () => { collapsedCategories = new Set() } @@ -220,17 +225,14 @@ categoryScrollContainer.scrollTo({ left: 0, behavior: 'smooth' }) } resetCollapsedCategories() + // Auto-expand library when searching + if (isLibraryCollapsed) { + isLibraryCollapsed = false + } } }) -{#snippet loadingSnippet()} -
- -

Loading…

-
-{/snippet} - {#snippet noResultsSnippet()}

Nothing found for "{searchQuery}"

@@ -345,123 +347,196 @@
-

Your Library

- + + {#if !isLibraryCollapsed} + + {/if}
-
-
- {#each categories as category} -
-
- - {#if !isCategoryCollapsed(category.id)} - - {/if} -
+ {#if !isLibraryCollapsed} +
+
+ {#each categories as category} +
+
+ + {#if !isCategoryCollapsed(category.id)} + + {/if} +
- {#if !isCategoryCollapsed(category.id)} -
- {#if category.id === 'notebooks'} - {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)} -
- {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())} -
{ - handleNotebookClick('drafts', event) + {#if !isCategoryCollapsed(category.id)} +
+ {#if category.id === 'notebooks'} + {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)} +
+ {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())} +
{ + handleNotebookClick('drafts', event) + }} + > + {}} + /> +
+ {/if} + + {#each notebooksList as notebook, i (notebook.id + i)} +
+ handleNotebookClick(notebook.id, e)} + onpin={() => handlePinNotebook(notebook.id)} + onunpin={() => handleUnPinNotebook(notebook.id)} + {@attach contextMenu({ + canOpen: true, + items: [ + !notebook.data.pinned + ? { + type: 'action', + text: 'Add to Favorites', + icon: 'heart', + action: () => handlePinNotebook(notebook.id) + } + : { + type: 'action', + text: 'Remove from Favorites', + icon: 'heart.off', + action: () => handleUnPinNotebook(notebook.id) + }, + { + type: 'action', + text: 'Customize', + icon: 'edit', + action: () => (isCustomizingNotebook = notebook) + }, + { + type: 'action', + kind: 'danger', + text: 'Delete', + icon: 'trash', + action: () => handleDeleteNotebook(notebook) + } + ] + })} + /> +
+ {/each} +
+ {/if} + {:else if category.id === 'notes'} +
    + {#if !notebookId} + - {}} - /> -
- {/if} - - {#each notebooksList as notebook, i (notebook.id + i)} -
+ {:else if notebookId === 'drafts'} + - handleNotebookClick(notebook.id, e)} - onpin={() => handlePinNotebook(notebook.id)} - onunpin={() => handleUnPinNotebook(notebook.id)} - {@attach contextMenu({ - canOpen: true, - items: [ - !notebook.data.pinned - ? { - type: 'action', - text: 'Add to Favorites', - icon: 'heart', - action: () => handlePinNotebook(notebook.id) - } - : { - type: 'action', - text: 'Remove from Favorites', - icon: 'heart.off', - action: () => handleUnPinNotebook(notebook.id) - }, - { - type: 'action', - text: 'Customize', - icon: 'edit', - action: () => (isCustomizingNotebook = notebook) - }, - { - type: 'action', - kind: 'danger', - text: 'Delete', - icon: 'trash', - action: () => handleDeleteNotebook(notebook) - } - ] - })} - /> -
- {/each} -
- {/if} - {:else if category.id === 'notes'} -
    + {#snippet children([resources, searchResult, searching])} + {@render notesList(searchResult ?? resources, resources)} + {/snippet} + + {:else} + + {#snippet children([notebook, searchResult, searching])} + {@render notesList( + filterNoteResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ), + filterNoteResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ) + )} + {/snippet} + + {/if} +
+ {:else if category.id === 'sources'} {#if !notebookId} {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} + {@render sourcesList(searchResult ?? resources, resources)} {/snippet} {:else if notebookId === 'drafts'} {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} + {@render sourcesList(searchResult ?? resources, resources)} {/snippet} {:else} @@ -512,104 +579,25 @@ fetchContents > {#snippet children([notebook, searchResult, searching])} - {@render notesList( - filterNoteResources(notebook?.contents ?? [], searchResult).map( - (e) => e.entry_id - ), - filterNoteResources(notebook?.contents ?? [], searchResult).map( + {@render sourcesList( + filterOtherResources(notebook?.contents ?? [], searchResult) + .slice(0, resourceRenderCnt) + .map((e) => e.entry_id), + filterOtherResources(notebook?.contents ?? [], searchResult).map( (e) => e.entry_id ) )} {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} - {/snippet} {/if} - - {:else if category.id === 'sources'} - {#if !notebookId} - - {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} - {/snippet} - - {:else if notebookId === 'drafts'} - - {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} - {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} - {/snippet} - - {:else} - - {#snippet children([notebook, searchResult, searching])} - {@render sourcesList( - filterOtherResources(notebook?.contents ?? [], searchResult) - .slice(0, resourceRenderCnt) - .map((e) => e.entry_id), - filterOtherResources(notebook?.contents ?? [], searchResult).map( - (e) => e.entry_id - ) - )} - {/snippet} - - {#snippet loading()} - {@render loadingSnippet()} - {/snippet} - {/if} - {/if} -
- {/if} -
- {/each} +
+ {/if} +
+ {/each} +
-
+ {/if}
From 823529ccb86cbe6d8a7f7b7eee3dbb063a62ec8c Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Wed, 12 Nov 2025 18:00:55 +0100 Subject: [PATCH 09/24] chore: move Drafts notebook init to notebooks list and have no results snippet for notebooks as well Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 179 +++++++++--------- 1 file changed, 94 insertions(+), 85 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 69ee5760e..df5d7fef8 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -19,9 +19,14 @@ } from '../../handlers/notebookOpenHandlers' import NotebookEditor from './NotebookEditor/NotebookEditor.svelte' import { conditionalArrayItem, SearchResourceTags, truncate, useThrottle } from '@deta/utils' - import { type OpenTarget, ResourceTypes, SpaceEntryOrigin } from '@deta/types' + import { type OpenTarget, type Option, ResourceTypes, SpaceEntryOrigin } from '@deta/types' import NotebookSidebarNoteName from './NotebookSidebarNoteName.svelte' - import { useResourceManager, Resource, getResourceCtxItems } from '@deta/services/resources' + import { + useResourceManager, + Resource, + getResourceCtxItems, + type ResourceSearchResult + } from '@deta/services/resources' import { useMessagePortClient } from '@deta/services/messagePort' import { promptForFilesAndTurnIntoResources } from '@deta/services' @@ -43,13 +48,35 @@ const notebookManager = useNotebookManager() const resourceManager = useResourceManager() + // TODO: have a sane way to manage `Drafts` in the notebook manager itself const notebooksList = $derived( - notebookManager.sortedNotebooks - .filter((e) => { - if (!searchQuery) return true - return e.nameValue.toLowerCase().includes(searchQuery.toLowerCase()) - }) - .sort((a, b) => (b.data.pinned === true) - (a.data.pinned === true)) + (() => { + const draftsNotebook = { + id: 'drafts', + nameValue: 'Drafts', + colorValue: [ + ['#5d5d62', '5d5d62'], + ['#2e2f34', '#2e2f34'], + ['#efefef', '#efefef'] + ] + } + + const filtered = notebookManager.sortedNotebooks + .filter((e) => { + if (!searchQuery) return true + return e.nameValue.toLowerCase().includes(searchQuery.toLowerCase()) + }) + .sort((a, b) => (b.data.pinned === true) - (a.data.pinned === true)) + + if (!searchQuery) { + return [draftsNotebook, ...filtered] + } else { + if (draftsNotebook.nameValue.toLowerCase().includes(searchQuery.toLowerCase())) { + return [draftsNotebook, ...filtered] + } + return filtered + } + })() ) const handleCreateNote = () => { @@ -233,16 +260,16 @@ }) -{#snippet noResultsSnippet()} +{#snippet noResultsSnippet(categoryLabel: string)}
-

Nothing found for "{searchQuery}"

+

No {categoryLabel} found for "{searchQuery}"

{/snippet} {#snippet notesList(visibleItems, allItems)} {#if allItems.length <= 0} {#if searchQuery.length > 0} - {@render noResultsSnippet()} + {@render noResultsSnippet('notes')} {:else}
@@ -276,7 +303,7 @@ {#snippet sourcesList(visibleItems, allItems)} {#if allItems.length <= 0} {#if searchQuery.length > 0} - {@render noResultsSnippet()} + {@render noResultsSnippet('media')} {:else}
@@ -388,80 +415,58 @@ {#if !isCategoryCollapsed(category.id)}
{#if category.id === 'notebooks'} - {#if !searchQuery || (searchQuery !== null && searchQuery.length > 0)} -
- {#if !searchQuery || 'drafts'.includes(searchQuery.trim().toLowerCase())} -
{ - handleNotebookClick('drafts', event) - }} - > - {}} - /> -
- {/if} - - {#each notebooksList as notebook, i (notebook.id + i)} -
- handleNotebookClick(notebook.id, e)} - onpin={() => handlePinNotebook(notebook.id)} - onunpin={() => handleUnPinNotebook(notebook.id)} - {@attach contextMenu({ - canOpen: true, - items: [ - !notebook.data.pinned - ? { - type: 'action', - text: 'Add to Favorites', - icon: 'heart', - action: () => handlePinNotebook(notebook.id) - } - : { - type: 'action', - text: 'Remove from Favorites', - icon: 'heart.off', - action: () => handleUnPinNotebook(notebook.id) - }, - { - type: 'action', - text: 'Customize', - icon: 'edit', - action: () => (isCustomizingNotebook = notebook) - }, - { - type: 'action', - kind: 'danger', - text: 'Delete', - icon: 'trash', - action: () => handleDeleteNotebook(notebook) - } - ] - })} - /> -
- {/each} -
+ {#if notebooksList.length <= 0 && searchQuery.length > 0} + {@render noResultsSnippet('notebooks')} {/if} +
+ {#each notebooksList as notebook, i (notebook.id + i)} +
+ handleNotebookClick(notebook.id, e)} + onpin={() => handlePinNotebook(notebook.id)} + onunpin={() => handleUnPinNotebook(notebook.id)} + {@attach contextMenu({ + canOpen: notebook.id !== 'drafts', + items: [ + !notebook.data.pinned + ? { + type: 'action', + text: 'Add to Favorites', + icon: 'heart', + action: () => handlePinNotebook(notebook.id) + } + : { + type: 'action', + text: 'Remove from Favorites', + icon: 'heart.off', + action: () => handleUnPinNotebook(notebook.id) + }, + { + type: 'action', + text: 'Customize', + icon: 'edit', + action: () => (isCustomizingNotebook = notebook) + }, + { + type: 'action', + kind: 'danger', + text: 'Delete', + icon: 'trash', + action: () => handleDeleteNotebook(notebook) + } + ] + })} + /> +
+ {/each} +
{:else if category.id === 'notes'}
    {#if !notebookId} @@ -834,6 +839,10 @@ color: light-dark(rgba(0, 0, 0, 0.25), rgba(255, 255, 255, 0.3)); text-align: center; text-wrap: pretty; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; h3 { color: light-dark(rgba(0, 0, 0, 0.75), rgba(255, 255, 255, 0.8)); From 262fac1c3462894ec2e57ef6285849945b913c78 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Thu, 13 Nov 2025 16:05:33 +0100 Subject: [PATCH 10/24] chore: remove collapsable library state from notebook contents view Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 345 +++++++++--------- 1 file changed, 163 insertions(+), 182 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index df5d7fef8..c013ff5a5 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -38,7 +38,6 @@ let searchQuery = $state('') let categoryScrollContainer = $state() let collapsedCategories = $state>(new Set(['notes', 'sources'])) - let isLibraryCollapsed = $state(true) let resourceRenderCnt = $state(20) const handleMediaWheel = useThrottle(() => { @@ -236,10 +235,6 @@ collapsedCategories = newCollapsed } - const toggleLibraryCollapse = () => { - isLibraryCollapsed = !isLibraryCollapsed - } - const resetCollapsedCategories = () => { collapsedCategories = new Set() } @@ -252,10 +247,6 @@ categoryScrollContainer.scrollTo({ left: 0, behavior: 'smooth' }) } resetCollapsedCategories() - // Auto-expand library when searching - if (isLibraryCollapsed) { - isLibraryCollapsed = false - } } }) @@ -374,174 +365,101 @@
    - - {#if !isLibraryCollapsed} - - {/if} +

    Your Library

    +
    - {#if !isLibraryCollapsed} -
    -
    - {#each categories as category} -
    -
    - - {#if !isCategoryCollapsed(category.id)} - - {/if} -
    - +
    +
    + {#each categories as category} +
    +
    + {#if !isCategoryCollapsed(category.id)} -
    - {#if category.id === 'notebooks'} - {#if notebooksList.length <= 0 && searchQuery.length > 0} - {@render noResultsSnippet('notebooks')} - {/if} -
    - {#each notebooksList as notebook, i (notebook.id + i)} -
    - handleNotebookClick(notebook.id, e)} - onpin={() => handlePinNotebook(notebook.id)} - onunpin={() => handleUnPinNotebook(notebook.id)} - {@attach contextMenu({ - canOpen: notebook.id !== 'drafts', - items: [ - !notebook.data.pinned - ? { - type: 'action', - text: 'Add to Favorites', - icon: 'heart', - action: () => handlePinNotebook(notebook.id) - } - : { - type: 'action', - text: 'Remove from Favorites', - icon: 'heart.off', - action: () => handleUnPinNotebook(notebook.id) - }, - { - type: 'action', - text: 'Customize', - icon: 'edit', - action: () => (isCustomizingNotebook = notebook) - }, - { - type: 'action', - kind: 'danger', - text: 'Delete', - icon: 'trash', - action: () => handleDeleteNotebook(notebook) - } - ] - })} - /> -
    - {/each} -
    - {:else if category.id === 'notes'} -
      - {#if !notebookId} - - {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {:else if notebookId === 'drafts'} - - {#snippet children([resources, searchResult, searching])} - {@render notesList(searchResult ?? resources, resources)} - {/snippet} - - {:else} - - {#snippet children([notebook, searchResult, searching])} - {@render notesList( - filterNoteResources(notebook?.contents ?? [], searchResult).map( - (e) => e.entry_id - ), - filterNoteResources(notebook?.contents ?? [], searchResult).map( - (e) => e.entry_id - ) - )} - {/snippet} - - {/if} -
    - {:else if category.id === 'sources'} + + {/if} +
    + + {#if !isCategoryCollapsed(category.id)} +
    + {#if category.id === 'notebooks'} + {#if notebooksList.length <= 0 && searchQuery.length > 0} + {@render noResultsSnippet('notebooks')} + {/if} +
    + {#each notebooksList as notebook, i (notebook.id + i)} +
    + handleNotebookClick(notebook.id, e)} + onpin={() => handlePinNotebook(notebook.id)} + onunpin={() => handleUnPinNotebook(notebook.id)} + {@attach contextMenu({ + canOpen: notebook.id !== 'drafts', + items: [ + !notebook.data.pinned + ? { + type: 'action', + text: 'Add to Favorites', + icon: 'heart', + action: () => handlePinNotebook(notebook.id) + } + : { + type: 'action', + text: 'Remove from Favorites', + icon: 'heart.off', + action: () => handleUnPinNotebook(notebook.id) + }, + { + type: 'action', + text: 'Customize', + icon: 'edit', + action: () => (isCustomizingNotebook = notebook) + }, + { + type: 'action', + kind: 'danger', + text: 'Delete', + icon: 'trash', + action: () => handleDeleteNotebook(notebook) + } + ] + })} + /> +
    + {/each} +
    + {:else if category.id === 'notes'} +
      {#if !notebookId} {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} + {@render notesList(searchResult ?? resources, resources)} {/snippet} {:else if notebookId === 'drafts'} {#snippet children([resources, searchResult, searching])} - {@render sourcesList(searchResult ?? resources, resources)} + {@render notesList(searchResult ?? resources, resources)} {/snippet} {:else} @@ -584,25 +502,88 @@ fetchContents > {#snippet children([notebook, searchResult, searching])} - {@render sourcesList( - filterOtherResources(notebook?.contents ?? [], searchResult) - .slice(0, resourceRenderCnt) - .map((e) => e.entry_id), - filterOtherResources(notebook?.contents ?? [], searchResult).map( + {@render notesList( + filterNoteResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ), + filterNoteResources(notebook?.contents ?? [], searchResult).map( (e) => e.entry_id ) )} {/snippet} {/if} +
    + {:else if category.id === 'sources'} + {#if !notebookId} + + {#snippet children([resources, searchResult, searching])} + {@render sourcesList(searchResult ?? resources, resources)} + {/snippet} + + {:else if notebookId === 'drafts'} + + {#snippet children([resources, searchResult, searching])} + {@render sourcesList(searchResult ?? resources, resources)} + {/snippet} + + {:else} + + {#snippet children([notebook, searchResult, searching])} + {@render sourcesList( + filterOtherResources(notebook?.contents ?? [], searchResult) + .slice(0, resourceRenderCnt) + .map((e) => e.entry_id), + filterOtherResources(notebook?.contents ?? [], searchResult).map( + (e) => e.entry_id + ) + )} + {/snippet} + {/if} -
    - {/if} -
    - {/each} -
    + {/if} +
    + {/if} +
    + {/each}
    - {/if} +
    From 16cca46ab4c7e98fbd4d0021adb16f95da659e51 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 15:06:13 +0100 Subject: [PATCH 18/24] chore: remove animations in NotebookContents view Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 71 ------------------- 1 file changed, 71 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 3b4cbb099..2ab740335 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -206,25 +206,6 @@ : () => handleRemoveFromNotebook(sourceNotebookId, resource.id) }) - const filterNoteResources = ( - resources: NotebookEntry[], - searchResults: Option - ) => { - if (searchResults) { - return searchResults.filter((e) => e.resource_type === ResourceTypes.DOCUMENT_SPACE_NOTE) - } else { - return resources.filter((e) => e.resource_type === ResourceTypes.DOCUMENT_SPACE_NOTE) - } - } - const filterOtherResources = ( - resources: NotebookEntry[], - searchResults: Option - ) => { - if (searchResults) { - return searchResults.filter((e) => e.resource_type !== ResourceTypes.DOCUMENT_SPACE_NOTE) - } else return resources.filter((e) => e.resource_type !== ResourceTypes.DOCUMENT_SPACE_NOTE) - } - const categories = $derived([ ...conditionalArrayItem(notebookId === undefined, { id: 'notebooks', @@ -636,18 +617,6 @@ flex-direction: column; gap: 1.5rem; padding-bottom: 1rem; - animation: slideDown 0.3s ease-out; - } - - @keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } } .category-section { @@ -698,7 +667,6 @@ .category-content { max-height: min(360px, 40vh); overflow-y: auto; - animation: expandCategory 0.3s ease-out; transform-origin: top; scroll-behavior: smooth; scrollbar-width: thin; @@ -731,56 +699,17 @@ } } - @keyframes expandCategory { - from { - opacity: 0; - transform: scaleY(0.95); - max-height: 0; - } - to { - opacity: 1; - transform: scaleY(1); - max-height: min(360px, 40vh); - } - } - .notebook-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.3ch, 1fr)); gap: 0.5rem; } - .notebook-wrapper { - animation: fadeInUp 0.4s ease-out backwards; - animation-delay: var(--delay, 0ms); - } - - @keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - .sources-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; grid-auto-rows: 60px; - animation: fadeIn 0.3s ease-out; - } - - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } } .load-more-trigger { From 225bad5e64ac3e54eb4546eb3f96f664e47ecd3d Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 17:00:22 +0100 Subject: [PATCH 19/24] fix: readd removed NotebookLoader file but use SurfLoader in notebook sidebar Signed-off-by: Aavash Shrestha --- .../notebook/NotebookSidebar.svelte | 13 +- .../components/Utils/NotebookLoader.svelte | 182 ++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/lib/components/Utils/NotebookLoader.svelte diff --git a/app/src/renderer/Resource/components/notebook/NotebookSidebar.svelte b/app/src/renderer/Resource/components/notebook/NotebookSidebar.svelte index 174316552..dac9f678d 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookSidebar.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookSidebar.svelte @@ -15,7 +15,7 @@ type OpenTarget, SpaceEntryOrigin } from '@deta/types' - import { NotebookLoader, SurfLoader, SourceCard } from '@deta/ui' + import { SurfLoader, SourceCard } from '@deta/ui' import { type Notebook } from '@deta/services/notebooks' import { type Resource, getResourceCtxItems } from '@deta/services/resources' import { @@ -252,7 +252,7 @@ {:else} - {#snippet children([notebook, searchResult, searching])}
    @@ -661,7 +662,7 @@ {/if} {/snippet} - + {/if} diff --git a/packages/ui/src/lib/components/Utils/NotebookLoader.svelte b/packages/ui/src/lib/components/Utils/NotebookLoader.svelte new file mode 100644 index 000000000..fa9a91a14 --- /dev/null +++ b/packages/ui/src/lib/components/Utils/NotebookLoader.svelte @@ -0,0 +1,182 @@ + + + +{#if isLoading} + {@render loading?.()} +{:else} + {@render children?.([notebook, searchResults, searching])} +{/if} + +{#snippet failed(error, reset)} + crash!? +

    {error}

    + +{/snippet} From 36fe1ea1d6f23ff80b269a2180ef6729bcd8fbe7 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 17:01:47 +0100 Subject: [PATCH 20/24] chore (backend): also use space id filter in other resource id list apis and pass filter from search api Signed-off-by: Aavash Shrestha --- packages/backend/src/store/resource_tags.rs | 33 +++++++++++++------ packages/backend/src/store/resources.rs | 7 ++++ packages/backend/src/store/search.rs | 20 ----------- .../backend/src/worker/handlers/resource.rs | 11 ++----- 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/store/resource_tags.rs b/packages/backend/src/store/resource_tags.rs index f50b01f5a..f7cba5ec9 100644 --- a/packages/backend/src/store/resource_tags.rs +++ b/packages/backend/src/store/resource_tags.rs @@ -223,18 +223,31 @@ impl Database { pub fn list_resource_ids_by_tags( &self, tags: &Vec, + space_id: Option, ) -> BackendResult> { let mut result = Vec::new(); - if tags.is_empty() { - return Ok(result); - } - let (query, params) = list_resource_ids_by_tags_query(tags, 0); - let mut stmt = self.conn.prepare(&query)?; - let resource_ids = - stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| row.get(0))?; - for resource_id in resource_ids { - result.push(resource_id?); + let mut cursor: Option = None; + let page_size = 100; + + loop { + let paginated_result = self.list_resource_ids_by_tags_paginated( + tags, + PaginationParams { + limit: page_size, + cursor: cursor.clone(), + }, + space_id.clone(), + )?; + + result.extend(paginated_result.items); + + if !paginated_result.has_more { + break; + } + + cursor = paginated_result.next_cursor; } + Ok(result) } @@ -242,7 +255,7 @@ impl Database { &self, tags: &Vec, pagination: PaginationParams, - // None = no space filter, Some("") = no space, Some(id) = specific space + // None = no space filter(all spaces), Some("") = does not belong to any space, Some(id) = specific space space_filter: Option<&str>, ) -> BackendResult> { if tags.is_empty() { diff --git a/packages/backend/src/store/resources.rs b/packages/backend/src/store/resources.rs index ee44b2184..672efe22c 100644 --- a/packages/backend/src/store/resources.rs +++ b/packages/backend/src/store/resources.rs @@ -152,6 +152,13 @@ impl Database { let mut stmt = self .conn .prepare("SELECT resource_id FROM space_entries WHERE space_id = ?1")?; + // TODO: document this behavior better + if space_id == "" { + stmt = self. + conn. + prepare("SELECT id from resources WHERE id NOT IN (SELECT resource_id FROM space_entries WHERE manually_added = 1)")?; + } + let resource_ids = stmt.query_map(rusqlite::params![space_id], |row| row.get(0))?; for resource_id in resource_ids { result.push(resource_id?); diff --git a/packages/backend/src/store/search.rs b/packages/backend/src/store/search.rs index de954c54e..ddb560117 100644 --- a/packages/backend/src/store/search.rs +++ b/packages/backend/src/store/search.rs @@ -156,26 +156,6 @@ impl Database { Ok(results) } - // search for resources that match the given tags and only return the resource ids - pub fn list_resources_by_tags( - &self, - tags: Vec, - ) -> BackendResult { - let filtered_resource_ids = self.list_resource_ids_by_tags(&tags)?; - - if filtered_resource_ids.is_empty() { - return Ok(SearchResultSimple { - items: vec![], - total: 0, - }); - } - - Ok(SearchResultSimple { - total: filtered_resource_ids.len() as i64, - items: filtered_resource_ids, - }) - } - pub fn list_all_resources_and_spaces( &self, resource_tags: Vec, diff --git a/packages/backend/src/worker/handlers/resource.rs b/packages/backend/src/worker/handlers/resource.rs index 763f33776..2758a4833 100644 --- a/packages/backend/src/worker/handlers/resource.rs +++ b/packages/backend/src/worker/handlers/resource.rs @@ -214,7 +214,7 @@ impl Worker { #[instrument(level = "trace", skip(self))] pub fn remove_resources_by_tags(&mut self, tags: Vec) -> BackendResult<()> { - let ids = self.db.list_resource_ids_by_tags(&tags)?; + let ids = self.db.list_resource_ids_by_tags(&tags, None)?; self.remove_resources(ids) } @@ -254,14 +254,9 @@ impl Worker { space_id: Option, ) -> BackendResult>> { if let Some(resource_tag_filters) = resource_tag_filters { - if let Some(space_id) = space_id { - return Ok(Some(self.db.list_resource_ids_by_tags_space_id( - &resource_tag_filters, - &space_id, - )?)); - } return Ok(Some( - self.db.list_resource_ids_by_tags(&resource_tag_filters)?, + self.db + .list_resource_ids_by_tags(&resource_tag_filters, space_id)?, )); } if let Some(space_id) = space_id { From e0cae2c59cbf1f17de70c1424f83955b6e9012fb Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 17:41:35 +0100 Subject: [PATCH 21/24] fix: use loading snippet for search as well as do not filter on resources and simply use search results in Surf Loader Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 66 ++++++++++--------- .../lib/components/Utils/SurfLoader.svelte | 15 +---- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 2ab740335..3dfbbbdcc 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -262,14 +262,22 @@ }) +{#snippet loadingSnippet()} +
    + +
    +{/snippet} + {#snippet noResultsSnippet(categoryLabel: string)}

    No {categoryLabel} found for "{searchQuery}"

    {/snippet} -{#snippet notesList({ resources, searchResults, searching, pagination, loadMore })} - {#if resources.length <= 0} +{#snippet notesList({ resources, searchResults, pagination, loadMore })} + {#if searchQuery && searchResults?.length === 0} + {@render noResultsSnippet('notes')} + {:else if resources.length <= 0} {#if searchQuery.length > 0} {@render noResultsSnippet('notes')} {:else} @@ -308,37 +316,32 @@ use:attachLoadMore={loadMore} > {#if pagination.isLoadingMore} -
    - - Loading more... -
    + {@render loadingSnippet()} {/if}
    {/if} {/if} {/snippet} -{#snippet sourcesList({ resources, searchResults, searching, pagination, loadMore })} - {#if resources.length <= 0} - {#if searchQuery.length > 0} - {@render noResultsSnippet('media')} - {:else} -
    -
    -

    Surf Media

    - -

    - Add media from across the web or your system to your notebook and to use it together - with Surf Notes to turn them into something great. -

    - -

    - Save web pages using the "Save" button while browsing, import local files or add - existing media from other notebooks by right-clicking them. -

    -
    +{#snippet sourcesList({ resources, searchResults, pagination, loadMore })} + {#if searchQuery && searchResults?.length === 0} + {@render noResultsSnippet('media')} + {:else if resources.length === 0} +
    +
    +

    Surf Media

    + +

    + Add media from across the web or your system to your notebook and to use it together with + Surf Notes to turn them into something great. +

    + +

    + Save web pages using the "Save" button while browsing, import local files or add existing + media from other notebooks by right-clicking them. +

    - {/if} +
    {:else}
    {#each searchResults ?? resources as resource, i (typeof resource === 'string' ? resource : resource.id + i)} @@ -370,10 +373,7 @@ use:attachLoadMore={loadMore} > {#if pagination.isLoadingMore} -
    - - Loading more... -
    + {@render loadingSnippet()} {/if}
    {/if} @@ -509,6 +509,9 @@ {#snippet children(loaderData)} {@render notesList(loaderData)} {/snippet} + {#snippet loading()} + {@render loadingSnippet()} + {/snippet}
{:else if category.id === 'sources'} @@ -529,6 +532,9 @@ {#snippet children(loaderData)} {@render sourcesList(loaderData)} {/snippet} + {#snippet loading()} + {@render loadingSnippet()} + {/snippet} {/if}
diff --git a/packages/ui/src/lib/components/Utils/SurfLoader.svelte b/packages/ui/src/lib/components/Utils/SurfLoader.svelte index f5bfbbb9a..e49fd4b36 100644 --- a/packages/ui/src/lib/components/Utils/SurfLoader.svelte +++ b/packages/ui/src/lib/components/Utils/SurfLoader.svelte @@ -96,18 +96,7 @@ } ) .then((results) => { - searchResults = results.resources - .sort( - (a, b) => - new Date(b.resource.updatedAt).getTime() - new Date(a.resource.updatedAt).getTime() - ) - .map((e) => e.resource) - .filter((e) => { - if (resources !== undefined) { - return resources.find((item) => item.id === e.id) - } - return e - }) + searchResults = results.resources.map((e) => e.resource) searching = false }) } catch (e) { @@ -221,7 +210,7 @@ }) -{#if isLoading} +{#if isLoading || searching} {@render loading?.()} {:else} {@render children?.({ From 3f06c3e5fa265575256109a91cf0c74d744513b2 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 17:49:58 +0100 Subject: [PATCH 22/24] chore: use differet header for notebook view and root view for notebook contents Signed-off-by: Aavash Shrestha --- .../notebook/NotebookContents.svelte | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte index 3dfbbbdcc..c59e66842 100644 --- a/app/src/renderer/Resource/components/notebook/NotebookContents.svelte +++ b/app/src/renderer/Resource/components/notebook/NotebookContents.svelte @@ -210,17 +210,26 @@ ...conditionalArrayItem(notebookId === undefined, { id: 'notebooks', label: 'Notebooks', - icon: 'notebook' + icon: { + main: 'notebook', + add: 'add' + } }), { id: 'notes', label: 'Notes', - icon: 'note' + icon: { + main: 'note', + add: 'add' + } }, { id: 'sources', label: 'Media', - icon: 'link' + icon: { + main: 'link', + add: 'folder.open' + } } ]) @@ -403,7 +412,9 @@
-

Your Library

+

+ {notebookId ? 'Your Notebook' : 'Your Library'} +

@@ -413,7 +424,7 @@
{/if}
From 77d052c176eb44a32fb2239d0460c154ed5f697a Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Tue, 18 Nov 2025 18:00:24 +0100 Subject: [PATCH 23/24] chore (backend): fix latest stable clippy warnings Signed-off-by: Aavash Shrestha --- packages/backend/src/store/models.rs | 7 ++----- packages/backend/src/store/resources.rs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/store/models.rs b/packages/backend/src/store/models.rs index 02d7e8246..695d834fb 100644 --- a/packages/backend/src/store/models.rs +++ b/packages/backend/src/store/models.rs @@ -535,7 +535,9 @@ pub struct PostProcessingJob { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[serde(tag = "type")] +#[derive(Default)] pub enum ResourceProcessingState { + #[default] Pending, Started, Failed { message: String }, @@ -558,11 +560,6 @@ impl FromSql for ResourceProcessingState { } } -impl Default for ResourceProcessingState { - fn default() -> Self { - Self::Pending - } -} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LegacyResourceTextContent { diff --git a/packages/backend/src/store/resources.rs b/packages/backend/src/store/resources.rs index 672efe22c..b92760479 100644 --- a/packages/backend/src/store/resources.rs +++ b/packages/backend/src/store/resources.rs @@ -153,7 +153,7 @@ impl Database { .conn .prepare("SELECT resource_id FROM space_entries WHERE space_id = ?1")?; // TODO: document this behavior better - if space_id == "" { + if space_id.is_empty() { stmt = self. conn. prepare("SELECT id from resources WHERE id NOT IN (SELECT resource_id FROM space_entries WHERE manually_added = 1)")?; From 1b4dd2aef84103aae44cbb48aa792840a6f95809 Mon Sep 17 00:00:00 2001 From: Aavash Shrestha Date: Wed, 19 Nov 2025 00:33:16 +0100 Subject: [PATCH 24/24] chore: remove resources provider as teleype provider Signed-off-by: Aavash Shrestha --- .../teletype/providers/ResourcesProvider.ts | 125 ------------------ .../src/lib/teletype/teletypeServiceCore.ts | 5 +- 2 files changed, 1 insertion(+), 129 deletions(-) delete mode 100644 packages/services/src/lib/teletype/providers/ResourcesProvider.ts diff --git a/packages/services/src/lib/teletype/providers/ResourcesProvider.ts b/packages/services/src/lib/teletype/providers/ResourcesProvider.ts deleted file mode 100644 index bee046e56..000000000 --- a/packages/services/src/lib/teletype/providers/ResourcesProvider.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { type MentionItem } from '@deta/editor' -import type { ActionProvider, TeletypeAction } from '../types' -import { - generateUUID, - useLogScope, - prependProtocol, - SearchResourceTags, - truncate, - getFileKind -} from '@deta/utils' -import { Resource, ResourceJSON, useResourceManager } from '../../resources' -import { ResourceTagsBuiltInKeys, ResourceTypes } from '@deta/types' -import { useBrowser } from '../../browser' - -export class ResourcesProvider implements ActionProvider { - readonly name = 'resources-search' - readonly isLocal = false - readonly maxActions = 9 - - private readonly log = useLogScope('ResourcesProvider') - private readonly resourceManager = useResourceManager() - private readonly browser = useBrowser() - - canHandle(query: string): boolean { - return query.trim().length >= 2 - } - - async getActions(query: string, _mentions: MentionItem[]): Promise { - const actions: TeletypeAction[] = [] - const trimmedQuery = query.trim() - - if (trimmedQuery.length < 2) return actions - - try { - const resources = await this.searchResources(trimmedQuery) - let noteCount = 0 - let otherCount = 0 - - resources.forEach((resource, index) => { - if (resource.type === ResourceTypes.DOCUMENT_SPACE_NOTE) { - if (noteCount >= 3) return - noteCount++ - } else { - if (otherCount >= 6) return - otherCount++ - } - - actions.push( - this.createSearchAction(resource, 80 - index, ['search', 'suggestion', 'google']) - ) - }) - } catch (error) { - this.log.error('Failed to fetch search suggestions:', error) - } - - return actions - } - - private createSearchAction( - resource: Resource, - priority: number, - keywords: string[] - ): TeletypeAction { - const url = - resource.metadata?.sourceURI ?? - resource.tags?.find((tag) => tag.name === ResourceTagsBuiltInKeys.CANONICAL_URL)?.value - const data = (resource as ResourceJSON).parsedData - - return { - id: generateUUID(), - name: truncate( - data?.title || - resource.metadata?.name || - url || - `${resource.id} - ${resource.type}` || - 'Undefined', - 30 - ), - icon: url - ? `favicon;;${url}` - : resource.type === ResourceTypes.DOCUMENT_SPACE_NOTE - ? 'note' - : `file;;${getFileKind(resource.type)}`, - section: resource.type === ResourceTypes.DOCUMENT_SPACE_NOTE ? 'Your Notes' : 'Saved Sources', - priority, - keywords, - description: ``, - buttonText: 'Open', - handler: async () => { - this.log.debug('Handling resource open', resource) - await this.browser.openResourceInCurrentTab(resource) - } - } - } - - private async searchResources(query: string): Promise { - try { - const results = await this.resourceManager.searchResources( - query, - [...SearchResourceTags.NonHiddenDefaultTags()], - { - includeAnnotations: false, - semanticEnabled: this.resourceManager.config.settingsValue.use_semantic_search - // semanticLimit: 0, - // keywordLimit: 6 - } - ) - - const resources = results.resources.map((x) => x.resource) - - // await Promise.all( - // resources.map((resource) => { - // if (resource instanceof ResourceJSON) { - // return resource.getParsedData() - // } - // }) - // ) - - return resources - } catch (error) { - this.log.error('Error fetching Google suggestions:', error) - return [] - } - } -} diff --git a/packages/services/src/lib/teletype/teletypeServiceCore.ts b/packages/services/src/lib/teletype/teletypeServiceCore.ts index c085441f6..fe444516d 100644 --- a/packages/services/src/lib/teletype/teletypeServiceCore.ts +++ b/packages/services/src/lib/teletype/teletypeServiceCore.ts @@ -1,10 +1,8 @@ import { useLogScope } from '@deta/utils' import type { ActionProvider, TeletypeAction, TeletypeServiceOptions } from './types' import { SearchProvider } from './providers/SearchProvider' -import { AskProvider } from './providers/AskProvider' import { type TeletypeActionSerialized, useMessagePortPrimary } from '../messagePort' -import { MentionItemType, type MentionItem } from '@deta/editor' -import { ResourcesProvider } from './providers/ResourcesProvider' +import { type MentionItem } from '@deta/editor' import { useBrowser } from '../browser' import { HostnameProvider } from './providers/HostnameProvider' import type { Fn } from '@deta/types' @@ -72,7 +70,6 @@ export class TeletypeServiceCore { // Register external/async providers this.registerProvider(new HostnameProvider()) // Async Hostname suggestions this.registerProvider(new SearchProvider()) // Async Google suggestions - this.registerProvider(new ResourcesProvider()) // SFFS Resources search this.registerProvider(new NotebooksProvider()) // Notebooks search }