experiments in a post-browser web
at main 4660 lines 200 kB view raw
1Modified regular file .nvmrc: 2 1 1: 2224 3Modified regular file app/datastore/viewer.js: 4 1 1: // Datastore Viewer 5 2 2: import api from '../api.js'; 6 3 : import izui from '../izui.js'; 7 4 3: 8 5 4: console.log('datastore viewer loading'); 9 6 5: 10 7 : // Initialize IZUI - this window is a child of settings, notify parent on close 11 8 : izui.init({ canHaveChildren: false }); 12 9 : 13 10 6: const tables = ['addresses', 'visits', 'content', 'tags', 'blobs', 'scripts_data', 'feeds']; 14 11 7: 15 12 8: let currentTable = null; 16 ... 17Modified regular file app/diagnostic.html: 18 ... 19 47 47: <div id="output"></div> 20 48 48: 21 49 49: <script type="module"> 22 50 : import izui from './izui.js'; 23 51 : 24 52 50: const api = window.app; 25 53 51: const output = document.getElementById('output'); 26 54 52: 27 55 : // Initialize IZUI - notify parent on close 28 56 : izui.init({ canHaveChildren: false }); 29 57 : 30 58 53: function log(title, content) { 31 59 54: const section = document.createElement('div'); 32 60 55: section.innerHTML = `<h2>${title}</h2><pre>${content}</pre>`; 33 ... 34Removed regular file app/izui.js: 35 1 : /** 36 2 : * IZUI: Inverted Zooming User Interface - Minimal Implementation 37 3 : * 38 4 : * Simple parent-child window tracking for focus restoration. 39 5 : * When a child window closes, focus returns to its parent. 40 6 : * 41 7 : * See notes/izui-model.md for the full design and future plans. 42 8 : */ 43 9 : 44 10 : import api from './api.js'; 45 11 : 46 12 : const DEBUG = false; 47 13 : 48 14 : /** 49 15 : * Notify parent window to take focus. 50 16 : * Called when this window is about to close/hide. 51 17 : * 52 18 : * @param {string} parentAddress - The parent window's address (source) 53 19 : */ 54 20 : export function notifyParentFocus(parentAddress) { 55 21 : if (!parentAddress) return; 56 22 : 57 23 : DEBUG && console.log('[IZUI] Notifying parent to focus:', parentAddress); 58 24 : 59 25 : // Publish to the parent's address so it receives the message 60 26 : api.publish('izui:focus-request', { 61 27 : from: window.location.toString() 62 28 : }, api.scopes.GLOBAL); 63 29 : } 64 30 : 65 31 : /** 66 32 : * Set up focus listener for this window. 67 33 : * When a child window closes, this window will receive focus. 68 34 : * 69 35 : * Call this in windows that may have children (e.g., settings). 70 36 : */ 71 37 : export function setupFocusListener() { 72 38 : const myAddress = window.location.toString(); 73 39 : 74 40 : api.subscribe('izui:focus-request', (msg) => { 75 41 : DEBUG && console.log('[IZUI] Received focus request from:', msg?.from); 76 42 : 77 43 : // Focus this window 78 44 : if (api.window && api.window.focus) { 79 45 : api.window.focus({}); 80 46 : } 81 47 : }, api.scopes.GLOBAL); 82 48 : 83 49 : DEBUG && console.log('[IZUI] Focus listener set up for:', myAddress); 84 50 : } 85 51 : 86 52 : /** 87 53 : * Open a child window with IZUI tracking. 88 54 : * The child will notify this window to focus when it closes. 89 55 : * 90 56 : * @param {string} address - URL to open 91 57 : * @param {Object} params - Window parameters 92 58 : * @returns {Promise<Object>} Window controller 93 59 : */ 94 60 : export async function openChildWindow(address, params = {}) { 95 61 : const myAddress = window.location.toString(); 96 62 : 97 63 : // Track this window as the parent 98 64 : const childParams = { 99 65 : ...params, 100 66 : izuiParent: myAddress 101 67 : }; 102 68 : 103 69 : DEBUG && console.log('[IZUI] Opening child window:', address, 'parent:', myAddress); 104 70 : 105 71 : const result = await api.window.open(address, childParams); 106 72 : return result; 107 73 : } 108 74 : 109 75 : /** 110 76 : * Handle ESC key for IZUI navigation. 111 77 : * If the window has internal navigation, handle that first. 112 78 : * Otherwise, close and notify parent. 113 79 : * 114 80 : * @param {Function} [internalHandler] - Optional handler for internal navigation 115 81 : * Should return { handled: true } if it handled the ESC internally 116 82 : * @returns {Function} Cleanup function 117 83 : */ 118 84 : export function setupEscapeHandler(internalHandler) { 119 85 : const handler = async () => { 120 86 : // First, try internal navigation 121 87 : if (internalHandler) { 122 88 : const result = await internalHandler(); 123 89 : if (result && result.handled) { 124 90 : DEBUG && console.log('[IZUI] Internal handler handled ESC'); 125 91 : return { handled: true }; 126 92 : } 127 93 : } 128 94 : 129 95 : // No internal navigation, notify parent before closing 130 96 : // The parent address is passed via window params, but we need to get it 131 97 : // For now, we just publish globally - the parent will pick it up 132 98 : notifyParentFocus(null); // Global publish 133 99 : 134 100 : DEBUG && console.log('[IZUI] ESC at root, closing'); 135 101 : return { handled: false }; 136 102 : }; 137 103 : 138 104 : // Register with the escape API 139 105 : if (api.escape && api.escape.onEscape) { 140 106 : api.escape.onEscape(handler); 141 107 : } 142 108 : 143 109 : return () => { 144 110 : // Cleanup - currently no way to unregister escape handler 145 111 : }; 146 112 : } 147 113 : 148 114 : /** 149 115 : * Initialize IZUI for a window. 150 116 : * Sets up focus listening and escape handling. 151 117 : * 152 118 : * @param {Object} options 153 119 : * @param {Function} [options.onEscape] - Internal escape handler 154 120 : * @param {boolean} [options.canHaveChildren=true] - Whether this window may open children 155 121 : */ 156 122 : export function init(options = {}) { 157 123 : const { onEscape, canHaveChildren = true } = options; 158 124 : 159 125 : if (canHaveChildren) { 160 126 : setupFocusListener(); 161 127 : } 162 128 : 163 129 : if (onEscape) { 164 130 : setupEscapeHandler(onEscape); 165 131 : } 166 132 : 167 133 : DEBUG && console.log('[IZUI] Initialized for:', window.location.toString()); 168 134 : } 169 135 : 170 136 : export default { 171 137 : init, 172 138 : setupFocusListener, 173 139 : setupEscapeHandler, 174 140 : openChildWindow, 175 141 : notifyParentFocus 176 142 : }; 177Modified regular file app/settings/settings.js: 178 1 1: import appConfig from '../config.js'; 179 2 2: import { createDatastoreStore } from '../utils.js'; 180 3 3: import api from '../api.js'; 181 4 4: import fc from '../features.js'; 182 5 : import izui from '../izui.js'; 183 6 5: 184 7 6: const DEBUG = api.debug; 185 8 7: const clear = false; 186 ... 1871212 1211: const allExtensions = []; 1881213 1212: 1891214 1213: // Get all builtin extension IDs from the loader 1901215 1214: const builtinExtIds = ['cmd', 'editor', 'groups', 'peeks', 'slides', 'windows']; 1911216 1215: 1921217 1216: // Add builtin extensions (whether running or not) 1931218 1217: builtinExtIds.forEach(extId => { 194 ... 1952524 2523: datastoreNav.textContent = 'Datastore'; 1962525 2524: datastoreNav.style.cursor = 'pointer'; 1972526 2525: datastoreNav.addEventListener('click', () => { 1982527 : izui.openChildWindow('peek://app/datastore/viewer.html', { 199 2526: api.window.open('peek://app/datastore/viewer.html', { 2002528 2527: width: 900, 2012529 2528: height: 600, 2022530 2529: key: 'datastore-viewer' 203 ... 2042538 2537: diagnosticNav.textContent = 'Diagnostic'; 2052539 2538: diagnosticNav.style.cursor = 'pointer'; 2062540 2539: diagnosticNav.addEventListener('click', () => { 2072541 : izui.openChildWindow('peek://app/diagnostic.html', { 208 2540: api.window.open('peek://app/diagnostic.html', { 2092542 2541: width: 900, 2102543 2542: height: 700, 2112544 2543: key: 'diagnostic-tool' 212 ... 2132574 2573: 2142575 2574: window.addEventListener('load', init); 2152576 2575: 2162577 : // Initialize IZUI for focus restoration when child windows close 2172578 : izui.init({ canHaveChildren: true }); 2182579 : 2192580 2576: window.addEventListener('blur', () => { 2202581 2577: console.log('core settings blur'); 2212582 2578: }); 222 ... 223Modified regular file backend/tauri-mobile/src-tauri/gen/apple/Peek/ShareViewController.swift: 224 ... 225 9 9: /// Schema version for tracking compatibility between Rust main app and Swift Share Extension. 226 10 10: /// Increment this when making schema changes that both codepaths must understand. 227 11 11: /// Both Rust and Swift code should use the same version number. 228 12 12: private let SCHEMA_VERSION = "12" 229 13 13: 230 14 14: // MARK: - Item Types 231 15 15: enum ItemType: String { 232 ... 233 36 36: struct TagRecord: Codable, FetchableRecord, PersistableRecord { 234 37 37: static let databaseTableName = "tags" 235 38 38: 236 39 39: var id: Int64String? 237 40 40: var name: String 238 41 41: var frequency: Int 239 42 42: var lastUsed: String 240 ... 241 49 49: static let databaseTableName = "item_tags" 242 50 50: 243 51 51: var item_id: String 244 52 52: var tag_id: Int64String 245 53 53: var created_at: String 246 54 54: } 247 55 55: 248 ... 249 226 226: 250 227 227: CREATE TABLE IF NOT EXISTS item_tags ( 251 228 228: item_id TEXT NOT NULL, 252 229 229: tag_id INTEGERTEXT NOT NULL, 253 230 230: created_at TEXT NOT NULL, 254 231 231: PRIMARY KEY (item_id, tag_id), 255 232 232: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 256 ... 257 272 272: 258 273 273: CREATE TABLE IF NOT EXISTS item_tags ( 259 274 274: item_id TEXT NOT NULL, 260 275 275: tag_id INTEGERTEXT NOT NULL, 261 276 276: created_at TEXT NOT NULL, 262 277 277: PRIMARY KEY (item_id, tag_id), 263 278 278: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 264 ... 265 289 289: print("[DB] Metadata column added") 266 290 290: } 267 291 291: 268 292 : // Create tags and settings tables (always needed) 269 292: // Create settings table first (needed for migration tracking) 270 293 293: try db.execute(sql: """ 271 294 : CREATE TABLE IF NOT EXISTS tags ( 272 295 : id INTEGER PRIMARY KEY AUTOINCREMENT, 273 296 : name TEXT NOT NULL UNIQUE, 274 297 : frequency INTEGER NOT NULL DEFAULT 0, 275 298 : lastUsed TEXT NOT NULL, 276 299 : frecencyScore REAL NOT NULL DEFAULT 0.0, 277 300 : createdAt TEXT NOT NULL, 278 301 : updatedAt TEXT NOT NULL 279 302 : ); 280 303 : 281 304 294: CREATE TABLE IF NOT EXISTS settings ( 282 305 295: key TEXT PRIMARY KEY, 283 306 296: value TEXT NOT NULL 284 307 297: ); 285 298: """) 286 299: 287 300: // Migrate tags.id from INTEGER to TEXT (for existing installs with old schema) 288 301: // Check if tags table exists and has INTEGER id column 289 302: if try db.tableExists("tags") { 290 303: let tagsHasIntegerId = try db.columns(in: "tags") 291 304: .contains { $0.name == "id" && $0.type.uppercased() == "INTEGER" } 292 305: 293 306: if tagsHasIntegerId { 294 307: print("[DB] Migrating tags.id from INTEGER to TEXT...") 295 308: 296 309: try db.inSavepoint { 297 310: // Step 1: Create temp mapping table 298 311: try db.execute(sql: """ 299 312: CREATE TEMP TABLE tag_id_mapping ( 300 313: old_id INTEGER PRIMARY KEY, 301 314: new_id TEXT NOT NULL 302 315: ); 303 316: """) 304 317: 305 318: // Step 2: Populate mapping with generated TEXT IDs 306 319: let oldIds = try Int64.fetchAll(db, sql: "SELECT id FROM tags") 307 320: for oldId in oldIds { 308 321: let newId = generateTagId() 309 322: try db.execute(sql: "INSERT INTO tag_id_mapping (old_id, new_id) VALUES (?, ?)", 310 323: arguments: [oldId, newId]) 311 324: } 312 325: 313 326: // Step 3: Create new tables with TEXT schema 314 327: try db.execute(sql: """ 315 328: CREATE TABLE tags_new ( 316 329: id TEXT PRIMARY KEY, 317 330: name TEXT NOT NULL UNIQUE, 318 331: frequency INTEGER NOT NULL DEFAULT 0, 319 332: lastUsed TEXT NOT NULL, 320 333: frecencyScore REAL NOT NULL DEFAULT 0.0, 321 334: createdAt TEXT NOT NULL, 322 335: updatedAt TEXT NOT NULL 323 336: ); 324 337: 325 338: CREATE TABLE item_tags_new ( 326 339: item_id TEXT NOT NULL, 327 340: tag_id TEXT NOT NULL, 328 341: created_at TEXT NOT NULL, 329 342: PRIMARY KEY (item_id, tag_id), 330 343: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 331 344: FOREIGN KEY (tag_id) REFERENCES tags_new(id) ON DELETE CASCADE 332 345: ); 333 346: """) 334 347: 335 348: // Step 4: Copy data using mapping 336 349: try db.execute(sql: """ 337 350: INSERT INTO tags_new (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 338 351: SELECT m.new_id, t.name, t.frequency, t.lastUsed, t.frecencyScore, t.createdAt, t.updatedAt 339 352: FROM tags t 340 353: JOIN tag_id_mapping m ON t.id = m.old_id; 341 354: 342 355: INSERT INTO item_tags_new (item_id, tag_id, created_at) 343 356: SELECT it.item_id, m.new_id, it.created_at 344 357: FROM item_tags it 345 358: JOIN tag_id_mapping m ON it.tag_id = m.old_id; 346 359: """) 347 360: 348 361: // Step 5: Swap tables 349 362: try db.execute(sql: """ 350 363: DROP TABLE item_tags; 351 364: DROP TABLE tags; 352 365: ALTER TABLE tags_new RENAME TO tags; 353 366: ALTER TABLE item_tags_new RENAME TO item_tags; 354 367: 355 368: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 356 369: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 357 370: 358 371: DROP TABLE IF EXISTS tag_id_mapping; 359 372: """) 360 373: 361 374: print("[DB] Tags INTEGER→TEXT migration completed successfully") 362 375: return .commit 363 376: } 364 377: } 365 378: } 366 379: 367 380: // Create tags table (for fresh install or if migration skipped) 368 381: if !(try db.tableExists("tags")) { 369 382: try db.execute(sql: """ 370 383: CREATE TABLE IF NOT EXISTS tags ( 371 384: id TEXT PRIMARY KEY, 372 385: name TEXT NOT NULL UNIQUE, 373 386: frequency INTEGER NOT NULL DEFAULT 0, 374 387: lastUsed TEXT NOT NULL, 375 388: frecencyScore REAL NOT NULL DEFAULT 0.0, 376 389: createdAt TEXT NOT NULL, 377 390: updatedAt TEXT NOT NULL 378 391: ); 379 392: 380 393: CREATE TABLE IF NOT EXISTS item_tags ( 381 394: item_id TEXT NOT NULL, 382 395: tag_id TEXT NOT NULL, 383 396: created_at TEXT NOT NULL, 384 397: PRIMARY KEY (item_id, tag_id), 385 398: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 386 399: FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 387 400: ); 388 401: 389 402: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 390 403: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 391 404: """) 392 405: } 393 406: 394 407: // Create blobs table and indexes 395 308 408: try db.execute(sql: """ 396 309 409: CREATE TABLE IF NOT EXISTS blobs ( 397 310 410: id TEXT PRIMARY KEY, 398 311 411: item_id TEXT NOT NULL, 399 ... 400 323 423: CREATE INDEX IF NOT EXISTS idx_items_url ON items(url); 401 324 424: CREATE INDEX IF NOT EXISTS idx_items_deleted ON items(deleted_at); 402 325 425: CREATE INDEX IF NOT EXISTS idx_items_sync_id ON items(sync_id); 403 326 : CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 404 327 : CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 405 328 426: CREATE INDEX IF NOT EXISTS idx_blobs_item_id ON blobs(item_id); 406 329 427: """) 407 330 428: } 408 ... 409 380 478: let wwwDomainPattern = "%://www.\(domain)/%" 410 381 479: let wwwDomainPatternRoot = "%://www.\(domain)" 411 382 480: 412 383 481: let domainTagIds = try Int64String.fetchSet(db, sql: """ 413 384 482: SELECT DISTINCT it.tag_id 414 385 483: FROM item_tags it 415 386 484: JOIN items i ON it.item_id = i.id 416 ... 417 391 489: 418 392 490: // Apply 2x multiplier to tags used on same-domain URLs 419 393 491: let boostedTags = records.map { record -> TagStats in 420 394 492: let boostedScore = domainTagIds.contains(record.id ?? -1"") 421 395 493: ? record.frecencyScore * 2.0 422 396 494: : record.frecencyScore 423 397 495: return TagStats( 424 ... 425 497 595: // Add tags 426 498 596: for tagName in tags { 427 499 597: // Get or create tag 428 500 598: var tagId: Int64String 429 501 599: 430 502 600: if let existingTag = try TagRecord.filter(Column("name") == tagName).fetchOne(db) { 431 503 601: tagId = existingTag.id! 432 ... 433 512 610: """, arguments: [newFrequency, now, frecency, now, existingTag.id!]) 434 513 611: } else { 435 514 612: // Create new tag 436 613: let newTagId = generateTagId() 437 515 614: let frecency = calculateFrecency(frequency: 1, lastUsed: now) 438 516 615: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now) 439 517 616: try newTag.insert(db) 440 518 617: tagId = db.lastInsertedRowIDnewTagId 441 519 618: } 442 520 619: 443 521 620: // Create item-tag association 444 ... 445 566 665: 446 567 666: // Add tags 447 568 667: for tagName in tags { 448 569 668: var tagId: Int64String 449 570 669: let isNewToItem = !existingTagNames.contains(tagName) 450 571 670: 451 572 671: if let existingTag = try TagRecord.filter(Column("name") == tagName).fetchOne(db) { 452 ... 453 588 687: """, arguments: [now, frecency, now, existingTag.id!]) 454 589 688: } 455 590 689: } else { 456 690: let newTagId = generateTagId() 457 591 691: let frecency = calculateFrecency(frequency: 1, lastUsed: now) 458 592 692: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now) 459 593 693: try newTag.insert(db) 460 594 694: tagId = db.lastInsertedRowIDnewTagId 461 595 695: } 462 596 696: 463 597 697: let itemTag = ItemTagRecord(item_id: imageId, tag_id: tagId, created_at: now) 464 ... 465 688 788: // Add tags 466 689 789: for tagName in tags { 467 690 790: // Get or create tag 468 691 791: var tagId: Int64String 469 692 792: // Only increment frequency if this tag is new to this item 470 693 793: let isNewToItem = !existingTagNames.contains(tagName) 471 694 794: 472 ... 473 714 814: } 474 715 815: } else { 475 716 816: // Create new tag 476 817: let newTagId = generateTagId() 477 717 818: let frecency = calculateFrecency(frequency: 1, lastUsed: now) 478 718 819: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now) 479 719 820: try newTag.insert(db) 480 720 821: tagId = db.lastInsertedRowIDnewTagId 481 721 822: } 482 722 823: 483 723 824: // Create item-tag association 484 ... 485 946 1047: 486 947 1048: return Double(frequency) * 10.0 * decayFactor 487 948 1049: } 488 1050: 489 1051: /// Generate a unique tag ID in format: tag_{timestamp}_{random9} 490 1052: private func generateTagId() -> String { 491 1053: let timestamp = Int(Date().timeIntervalSince1970 * 1000) 492 1054: let random = UUID().uuidString.prefix(9).lowercased() 493 1055: return "tag_\(timestamp)_\(random)" 494 1056: } 495 949 1057: } 496 950 1058: 497 951 1059: // MARK: - ISO8601 Formatter Extension 498 ... 499Modified regular file backend/tauri-mobile/src-tauri/src/lib.rs: 500 ... 501 918 918: /// Schema version for tracking compatibility between Rust main app and Swift Share Extension. 502 919 919: /// Increment this when making schema changes that both codepaths must understand. 503 920 920: /// Both Rust and Swift code should use the same version number. 504 921 921: const SCHEMA_VERSION: &str = "12"; 505 922 922: 506 923 923: fn ensure_database_initialized() -> Result<(), String> { 507 924 924: let mut init_result: Result<(), String> = Ok(()); 508 ... 5091037 1037: ); 5101038 1038: 5111039 1039: CREATE TABLE IF NOT EXISTS tags ( 5121040 1040: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT, 5131041 1041: name TEXT NOT NULL UNIQUE, 5141042 1042: frequency INTEGER NOT NULL DEFAULT 0, 5151043 1043: lastUsed TEXT NOT NULL, 516 ... 5171048 1048: 5181049 1049: CREATE TABLE IF NOT EXISTS item_tags ( 5191050 1050: item_id TEXT NOT NULL, 5201051 1051: tag_id INTEGERTEXT NOT NULL, 5211052 1052: created_at TEXT NOT NULL, 5221053 1053: PRIMARY KEY (item_id, tag_id), 5231054 1054: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 524 ... 5251203 1203: } 5261204 1204: } 5271205 1205: 5281206 : // Ensure tags and settings tables exist (for migration case) 529 1206: // Ensure settings table exists (for migration case) 5301207 1207: if let Err(e) = conn.execute_batch( 5311208 1208: " 5321209 : CREATE TABLE IF NOT EXISTS tags ( 5331210 : id INTEGER PRIMARY KEY AUTOINCREMENT, 5341211 : name TEXT NOT NULL UNIQUE, 5351212 : frequency INTEGER NOT NULL DEFAULT 0, 5361213 : lastUsed TEXT NOT NULL, 5371214 : frecencyScore REAL NOT NULL DEFAULT 0.0, 5381215 : createdAt TEXT NOT NULL, 5391216 : updatedAt TEXT NOT NULL 5401217 : ); 5411218 : 5421219 1209: CREATE TABLE IF NOT EXISTS settings ( 5431220 1210: key TEXT PRIMARY KEY, 5441221 1211: value TEXT NOT NULL 5451222 1212: ); 5461223 : 5471224 : CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 5481225 : CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 5491226 1213: ", 5501227 1214: ) { 5511228 : init_result = Err(format!("Failed to ensure auxiliary tables: {}", e)); 552 1215: init_result = Err(format!("Failed to ensure settings table: {}", e)); 5531229 1216: return; 5541230 1217: } 5551231 1218: 556 1219: // Migrate tags.id from INTEGER to TEXT (for existing installs with old schema) 557 1220: // Check if tags table exists and has INTEGER id column 558 1221: let tags_has_integer_id: bool = conn 559 1222: .query_row( 560 1223: "SELECT type FROM pragma_table_info('tags') WHERE name='id'", 561 1224: [], 562 1225: |row| row.get::<_, String>(0), 563 1226: ) 564 1227: .map(|t| t.to_uppercase() == "INTEGER") 565 1228: .unwrap_or(false); 566 1229: 567 1230: if tags_has_integer_id { 568 1231: ios_log("Migrating tags.id from INTEGER to TEXT..."); 569 1232: 570 1233: // Use savepoint for atomic rollback on error 571 1234: if let Err(e) = conn.execute("SAVEPOINT before_tag_migration", []) { 572 1235: ios_log(&format!("Failed to create savepoint: {}", e)); 573 1236: } else { 574 1237: let migration_result: Result<(), String> = (|| { 575 1238: // Step 1: Create temp mapping table 576 1239: conn.execute_batch( 577 1240: "CREATE TEMP TABLE tag_id_mapping ( 578 1241: old_id INTEGER PRIMARY KEY, 579 1242: new_id TEXT NOT NULL 580 1243: );" 581 1244: ).map_err(|e| format!("Failed to create mapping table: {}", e))?; 582 1245: 583 1246: // Step 2: Populate mapping with generated TEXT IDs 584 1247: let mut stmt = conn.prepare("SELECT id FROM tags") 585 1248: .map_err(|e| format!("Failed to prepare tag select: {}", e))?; 586 1249: let old_ids: Vec<i64> = stmt 587 1250: .query_map([], |row| row.get::<_, i64>(0)) 588 1251: .map_err(|e| format!("Failed to query tags: {}", e))? 589 1252: .filter_map(|r| r.ok()) 590 1253: .collect(); 591 1254: drop(stmt); 592 1255: 593 1256: for old_id in old_ids { 594 1257: let new_id = generate_tag_id(); 595 1258: conn.execute( 596 1259: "INSERT INTO tag_id_mapping (old_id, new_id) VALUES (?, ?)", 597 1260: params![old_id, new_id], 598 1261: ).map_err(|e| format!("Failed to insert mapping: {}", e))?; 599 1262: } 600 1263: 601 1264: // Step 3: Create new tables with TEXT schema 602 1265: conn.execute_batch( 603 1266: "CREATE TABLE tags_new ( 604 1267: id TEXT PRIMARY KEY, 605 1268: name TEXT NOT NULL UNIQUE, 606 1269: frequency INTEGER NOT NULL DEFAULT 0, 607 1270: lastUsed TEXT NOT NULL, 608 1271: frecencyScore REAL NOT NULL DEFAULT 0.0, 609 1272: createdAt TEXT NOT NULL, 610 1273: updatedAt TEXT NOT NULL 611 1274: ); 612 1275: 613 1276: CREATE TABLE item_tags_new ( 614 1277: item_id TEXT NOT NULL, 615 1278: tag_id TEXT NOT NULL, 616 1279: created_at TEXT NOT NULL, 617 1280: PRIMARY KEY (item_id, tag_id), 618 1281: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 619 1282: FOREIGN KEY (tag_id) REFERENCES tags_new(id) ON DELETE CASCADE 620 1283: );" 621 1284: ).map_err(|e| format!("Failed to create new tables: {}", e))?; 622 1285: 623 1286: // Step 4: Copy data using mapping 624 1287: conn.execute_batch( 625 1288: "INSERT INTO tags_new (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 626 1289: SELECT m.new_id, t.name, t.frequency, t.lastUsed, t.frecencyScore, t.createdAt, t.updatedAt 627 1290: FROM tags t 628 1291: JOIN tag_id_mapping m ON t.id = m.old_id; 629 1292: 630 1293: INSERT INTO item_tags_new (item_id, tag_id, created_at) 631 1294: SELECT it.item_id, m.new_id, it.created_at 632 1295: FROM item_tags it 633 1296: JOIN tag_id_mapping m ON it.tag_id = m.old_id;" 634 1297: ).map_err(|e| format!("Failed to copy data: {}", e))?; 635 1298: 636 1299: // Step 5: Swap tables 637 1300: conn.execute_batch( 638 1301: "DROP TABLE item_tags; 639 1302: DROP TABLE tags; 640 1303: ALTER TABLE tags_new RENAME TO tags; 641 1304: ALTER TABLE item_tags_new RENAME TO item_tags; 642 1305: 643 1306: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 644 1307: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);" 645 1308: ).map_err(|e| format!("Failed to swap tables: {}", e))?; 646 1309: 647 1310: // Cleanup temp table 648 1311: let _ = conn.execute("DROP TABLE IF EXISTS tag_id_mapping", []); 649 1312: 650 1313: Ok(()) 651 1314: })(); 652 1315: 653 1316: match migration_result { 654 1317: Ok(()) => { 655 1318: let _ = conn.execute("RELEASE SAVEPOINT before_tag_migration", []); 656 1319: ios_log("Tags INTEGER→TEXT migration completed successfully"); 657 1320: } 658 1321: Err(e) => { 659 1322: ios_log(&format!("Tags migration failed, rolling back: {}", e)); 660 1323: let _ = conn.execute("ROLLBACK TO SAVEPOINT before_tag_migration", []); 661 1324: let _ = conn.execute("RELEASE SAVEPOINT before_tag_migration", []); 662 1325: } 663 1326: } 664 1327: } 665 1328: } 666 1329: 667 1330: // Ensure tags table exists (for fresh install or if migration skipped) 668 1331: let has_tags_table: bool = conn 669 1332: .query_row( 670 1333: "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tags'", 671 1334: [], 672 1335: |row| row.get::<_, i64>(0), 673 1336: ) 674 1337: .unwrap_or(0) > 0; 675 1338: 676 1339: if !has_tags_table { 677 1340: if let Err(e) = conn.execute_batch( 678 1341: " 679 1342: CREATE TABLE IF NOT EXISTS tags ( 680 1343: id TEXT PRIMARY KEY, 681 1344: name TEXT NOT NULL UNIQUE, 682 1345: frequency INTEGER NOT NULL DEFAULT 0, 683 1346: lastUsed TEXT NOT NULL, 684 1347: frecencyScore REAL NOT NULL DEFAULT 0.0, 685 1348: createdAt TEXT NOT NULL, 686 1349: updatedAt TEXT NOT NULL 687 1350: ); 688 1351: 689 1352: CREATE TABLE IF NOT EXISTS item_tags ( 690 1353: item_id TEXT NOT NULL, 691 1354: tag_id TEXT NOT NULL, 692 1355: created_at TEXT NOT NULL, 693 1356: PRIMARY KEY (item_id, tag_id), 694 1357: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 695 1358: FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 696 1359: ); 697 1360: 698 1361: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 699 1362: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 700 1363: ", 701 1364: ) { 702 1365: init_result = Err(format!("Failed to create tags tables: {}", e)); 703 1366: return; 704 1367: } 705 1368: } 706 1369: 7071232 1370: // One-time dedup migration: remove duplicate items 7081233 1371: let dedup_done: bool = conn 7091234 1372: .query_row( 710 ... 7111467 1605: frequency as f64 * 10.0 * decay_factor 7121468 1606: } 7131469 1607: 714 1608: // Generate a unique tag ID in format: tag_{timestamp}_{random9} 715 1609: fn generate_tag_id() -> String { 716 1610: use std::time::{SystemTime, UNIX_EPOCH}; 717 1611: let timestamp = SystemTime::now() 718 1612: .duration_since(UNIX_EPOCH) 719 1613: .unwrap() 720 1614: .as_millis(); 721 1615: let uuid = uuid::Uuid::new_v4().to_string(); 722 1616: let random = &uuid[0..9]; // First 9 chars of UUID 723 1617: format!("tag_{}_{}", timestamp, random) 724 1618: } 725 1619: 7261470 1620: // Helper to get webhook config 7271471 1621: fn get_webhook_config() -> (Option<String>, Option<String>) { 7281472 1622: let config = load_profile_config(); 729 ... 7301915 2065: // Add tags 7311916 2066: for tag_name in &tags { 7321917 2067: // Get or create tag 7331918 2068: let tag_id: i64String = match conn.query_row( 7341919 2069: "SELECT id FROM tags WHERE name = ?", 7351920 2070: params![tag_name], 7361921 2071: |row| row.get(0), 737 ... 7381925 2075: let frequency: u32 = conn 7391926 2076: .query_row( 7401927 2077: "SELECT frequency FROM tags WHERE id = ?", 7411928 2078: params![&id], 7421929 2079: |row| row.get(0), 7431930 2080: ) 7441931 2081: .unwrap_or(0); 745 ... 7461935 2085: 7471936 2086: conn.execute( 7481937 2087: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 7491938 2088: params![new_frequency, &now, frecency, &now, &id], 7501939 2089: ) 7511940 2090: .map_err(|e| format!("Failed to update tag: {}", e))?; 7521941 2091: 7531942 2092: id 7541943 2093: } 7551944 2094: Err(_) => { 7561945 2095: // Create new tag 757 2096: let new_tag_id = generate_tag_id(); 7581946 2097: let frecency = calculate_frecency(1, &now); 7591947 2098: conn.execute( 7601948 2099: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 7611949 2100: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 7621950 2101: ) 7631951 2102: .map_err(|e| format!("Failed to insert tag: {}", e))?; 7641952 2103: 7651953 2104: conn.last_insert_rowid()new_tag_id 7661954 2105: } 7671955 2106: }; 7681956 2107: 769 ... 7702025 2176: .prepare("SELECT id, name, frequency, lastUsed, frecencyScore FROM tags") 7712026 2177: .map_err(|e| format!("Failed to prepare query: {}", e))?; 7722027 2178: 7732028 2179: let tags_with_ids: Vec<(i64String, TagStats)> = stmt 7742029 2180: .query_map([], |row| { 7752030 2181: Ok(( 7762031 2182: row.get(0)?, 777 ... 7782057 2208: ) 7792058 2209: .map_err(|e| format!("Failed to prepare domain query: {}", e))?; 7802059 2210: 7812060 2211: let domain_tag_ids: std::collections::HashSet<i64String> = domain_stmt 7822061 2212: .query_map( 7832062 2213: params![&domain_pattern, &domain_pattern_root, &www_domain_pattern, &www_domain_pattern_root], 7842063 2214: |row| row.get(0), 785 ... 7862213 2364: // Add only the tags that are new to this item 7872214 2365: for tag_name in &tags_to_add { 7882215 2366: // Get or create tag 7892216 2367: let tag_id: i64String = match conn.query_row( 7902217 2368: "SELECT id FROM tags WHERE name = ?", 7912218 2369: params![tag_name], 7922219 2370: |row| row.get(0), 793 ... 7942223 2374: let frequency: u32 = conn 7952224 2375: .query_row( 7962225 2376: "SELECT frequency FROM tags WHERE id = ?", 7972226 2377: params![&existing_id], 7982227 2378: |row| row.get(0), 7992228 2379: ) 8002229 2380: .unwrap_or(0); 801 ... 8022233 2384: 8032234 2385: conn.execute( 8042235 2386: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 8052236 2387: params![new_frequency, &now, frecency, &now, &existing_id], 8062237 2388: ) 8072238 2389: .map_err(|e| format!("Failed to update tag: {}", e))?; 8082239 2390: 8092240 2391: existing_id 8102241 2392: } 8112242 2393: Err(_) => { 8122243 2394: // Create new tag 813 2395: let new_tag_id = generate_tag_id(); 8142244 2396: let frecency = calculate_frecency(1, &now); 8152245 2397: conn.execute( 8162246 2398: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 8172247 2399: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 8182248 2400: ) 8192249 2401: .map_err(|e| format!("Failed to insert tag: {}", e))?; 8202250 2402: 8212251 2403: conn.last_insert_rowid()new_tag_id 8222252 2404: } 8232253 2405: }; 8242254 2406: 825 ... 8262339 2491: // Add only the tags that are new to this item 8272340 2492: for tag_name in &tags_to_add { 8282341 2493: // Get or create tag 8292342 2494: let tag_id: i64String = match conn.query_row( 8302343 2495: "SELECT id FROM tags WHERE name = ?", 8312344 2496: params![tag_name], 8322345 2497: |row| row.get(0), 833 ... 8342349 2501: let frequency: u32 = conn 8352350 2502: .query_row( 8362351 2503: "SELECT frequency FROM tags WHERE id = ?", 8372352 2504: params![&existing_id], 8382353 2505: |row| row.get(0), 8392354 2506: ) 8402355 2507: .unwrap_or(0); 841 ... 8422359 2511: 8432360 2512: conn.execute( 8442361 2513: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 8452362 2514: params![new_frequency, &now, frecency, &now, &existing_id], 8462363 2515: ) 8472364 2516: .map_err(|e| format!("Failed to update tag: {}", e))?; 8482365 2517: 8492366 2518: existing_id 8502367 2519: } 8512368 2520: Err(_) => { 8522369 2521: // Create new tag 853 2522: let new_tag_id = generate_tag_id(); 8542370 2523: let frecency = calculate_frecency(1, &now); 8552371 2524: conn.execute( 8562372 2525: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 8572373 2526: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 8582374 2527: ) 8592375 2528: .map_err(|e| format!("Failed to insert tag: {}", e))?; 8602376 2529: 8612377 2530: conn.last_insert_rowid()new_tag_id 8622378 2531: } 8632379 2532: }; 8642380 2533: 865 ... 8662439 2592: 8672440 2593: // Add tags 8682441 2594: for tag_name in &all_tags { 8692442 2595: let tag_id: i64String = match conn.query_row( 8702443 2596: "SELECT id FROM tags WHERE name = ?", 8712444 2597: params![tag_name], 8722445 2598: |row| row.get(0), 873 ... 8742448 2601: let frequency: u32 = conn 8752449 2602: .query_row( 8762450 2603: "SELECT frequency FROM tags WHERE id = ?", 8772451 2604: params![&existing_id], 8782452 2605: |row| row.get(0), 8792453 2606: ) 8802454 2607: .unwrap_or(0); 881 ... 8822458 2611: 8832459 2612: conn.execute( 8842460 2613: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 8852461 2614: params![new_frequency, &now, frecency, &now, &existing_id], 8862462 2615: ) 8872463 2616: .map_err(|e| format!("Failed to update tag: {}", e))?; 8882464 2617: 8892465 2618: existing_id 8902466 2619: } 8912467 2620: Err(_) => { 892 2621: let new_tag_id = generate_tag_id(); 8932468 2622: let frecency = calculate_frecency(1, &now); 8942469 2623: conn.execute( 8952470 2624: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 8962471 2625: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 8972472 2626: ) 8982473 2627: .map_err(|e| format!("Failed to insert tag: {}", e))?; 8992474 2628: 9002475 2629: conn.last_insert_rowid()new_tag_id 9012476 2630: } 9022477 2631: }; 9032478 2632: 904 ... 9052516 2670: 9062517 2671: // Add tags 9072518 2672: for tag_name in &tags { 9082519 2673: let tag_id: i64String = match conn.query_row( 9092520 2674: "SELECT id FROM tags WHERE name = ?", 9102521 2675: params![tag_name], 9112522 2676: |row| row.get(0), 912 ... 9132525 2679: let frequency: u32 = conn 9142526 2680: .query_row( 9152527 2681: "SELECT frequency FROM tags WHERE id = ?", 9162528 2682: params![&existing_id], 9172529 2683: |row| row.get(0), 9182530 2684: ) 9192531 2685: .unwrap_or(0); 920 ... 9212535 2689: 9222536 2690: conn.execute( 9232537 2691: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 9242538 2692: params![new_frequency, &now, frecency, &now, &existing_id], 9252539 2693: ) 9262540 2694: .map_err(|e| format!("Failed to update tag: {}", e))?; 9272541 2695: 9282542 2696: existing_id 9292543 2697: } 9302544 2698: Err(_) => { 931 2699: let new_tag_id = generate_tag_id(); 9322545 2700: let frecency = calculate_frecency(1, &now); 9332546 2701: conn.execute( 9342547 2702: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 9352548 2703: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 9362549 2704: ) 9372550 2705: .map_err(|e| format!("Failed to insert tag: {}", e))?; 9382551 2706: 9392552 2707: conn.last_insert_rowid()new_tag_id 9402553 2708: } 9412554 2709: }; 9422555 2710: 943 ... 9442727 2882: 9452728 2883: // Add new tags 9462729 2884: for tag_name in &tags_to_add { 9472730 2885: let tag_id: i64String = match conn.query_row( 9482731 2886: "SELECT id FROM tags WHERE name = ?", 9492732 2887: params![tag_name], 9502733 2888: |row| row.get(0), 951 ... 9522736 2891: let frequency: u32 = conn 9532737 2892: .query_row( 9542738 2893: "SELECT frequency FROM tags WHERE id = ?", 9552739 2894: params![&existing_id], 9562740 2895: |row| row.get(0), 9572741 2896: ) 9582742 2897: .unwrap_or(0); 959 ... 9602746 2901: 9612747 2902: conn.execute( 9622748 2903: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 9632749 2904: params![new_frequency, &now, frecency, &now, &existing_id], 9642750 2905: ) 9652751 2906: .map_err(|e| format!("Failed to update tag: {}", e))?; 9662752 2907: 9672753 2908: existing_id 9682754 2909: } 9692755 2910: Err(_) => { 970 2911: let new_tag_id = generate_tag_id(); 9712756 2912: let frecency = calculate_frecency(1, &now); 9722757 2913: conn.execute( 9732758 2914: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 9742759 2915: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 9752760 2916: ) 9762761 2917: .map_err(|e| format!("Failed to insert tag: {}", e))?; 9772762 2918: 9782763 2919: conn.last_insert_rowid()new_tag_id 9792764 2920: } 9802765 2921: }; 9812766 2922: 982 ... 9832851 3007: 9842852 3008: // Add new tags 9852853 3009: for tag_name in &tags_to_add { 9862854 3010: let tag_id: i64String = match conn.query_row( 9872855 3011: "SELECT id FROM tags WHERE name = ?", 9882856 3012: params![tag_name], 9892857 3013: |row| row.get(0), 990 ... 9912860 3016: let frequency: u32 = conn 9922861 3017: .query_row( 9932862 3018: "SELECT frequency FROM tags WHERE id = ?", 9942863 3019: params![&existing_id], 9952864 3020: |row| row.get(0), 9962865 3021: ) 9972866 3022: .unwrap_or(0); 998 ... 9992870 3026: 10002871 3027: conn.execute( 10012872 3028: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 10022873 3029: params![new_frequency, &now, frecency, &now, &existing_id], 10032874 3030: ) 10042875 3031: .map_err(|e| format!("Failed to update tag: {}", e))?; 10052876 3032: 10062877 3033: existing_id 10072878 3034: } 10082879 3035: Err(_) => { 1009 3036: let new_tag_id = generate_tag_id(); 10102880 3037: let frecency = calculate_frecency(1, &now); 10112881 3038: conn.execute( 10122882 3039: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 10132883 3040: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 10142884 3041: ) 10152885 3042: .map_err(|e| format!("Failed to insert tag: {}", e))?; 10162886 3043: 10172887 3044: conn.last_insert_rowid()new_tag_id 10182888 3045: } 10192889 3046: }; 10202890 3047: 1021 ... 10222959 3116: 10232960 3117: // Add tags 10242961 3118: for tag_name in &tags { 10252962 3119: let tag_id: i64String = match conn.query_row( 10262963 3120: "SELECT id FROM tags WHERE name = ?", 10272964 3121: params![tag_name], 10282965 3122: |row| row.get(0), 1029 ... 10302968 3125: let frequency: u32 = conn 10312969 3126: .query_row( 10322970 3127: "SELECT frequency FROM tags WHERE id = ?", 10332971 3128: params![&existing_id], 10342972 3129: |row| row.get(0), 10352973 3130: ) 10362974 3131: .unwrap_or(0); 1037 ... 10382978 3135: 10392979 3136: conn.execute( 10402980 3137: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 10412981 3138: params![new_frequency, &now, frecency, &now, &existing_id], 10422982 3139: ) 10432983 3140: .map_err(|e| format!("Failed to update tag: {}", e))?; 10442984 3141: 10452985 3142: existing_id 10462986 3143: } 10472987 3144: Err(_) => { 1048 3145: let new_tag_id = generate_tag_id(); 10492988 3146: let frecency = calculate_frecency(1, &now); 10502989 3147: conn.execute( 10512990 3148: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 10522991 3149: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 10532992 3150: ) 10542993 3151: .map_err(|e| format!("Failed to insert tag: {}", e))?; 10552994 3152: 10562995 3153: conn.last_insert_rowid()new_tag_id 10572996 3154: } 10582997 3155: }; 10592998 3156: 1060 ... 10613169 3327: 10623170 3328: // Add new tags 10633171 3329: for tag_name in &tags_to_add { 10643172 3330: let tag_id: i64String = match conn.query_row( 10653173 3331: "SELECT id FROM tags WHERE name = ?", 10663174 3332: params![tag_name], 10673175 3333: |row| row.get(0), 1068 ... 10693178 3336: let frequency: u32 = conn 10703179 3337: .query_row( 10713180 3338: "SELECT frequency FROM tags WHERE id = ?", 10723181 3339: params![&existing_id], 10733182 3340: |row| row.get(0), 10743183 3341: ) 10753184 3342: .unwrap_or(0); 1076 ... 10773188 3346: 10783189 3347: conn.execute( 10793190 3348: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 10803191 3349: params![new_frequency, &now, frecency, &now, &existing_id], 10813192 3350: ) 10823193 3351: .map_err(|e| format!("Failed to update tag: {}", e))?; 10833194 3352: 10843195 3353: existing_id 10853196 3354: } 10863197 3355: Err(_) => { 1087 3356: let new_tag_id = generate_tag_id(); 10883198 3357: let frecency = calculate_frecency(1, &now); 10893199 3358: conn.execute( 10903200 3359: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 10913201 3360: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 10923202 3361: ) 10933203 3362: .map_err(|e| format!("Failed to insert tag: {}", e))?; 10943204 3363: 10953205 3364: conn.last_insert_rowid()new_tag_id 10963206 3365: } 10973207 3366: }; 10983208 3367: 1099 ... 11003930 4089: // Add new tags 11013931 4090: for tag_name in tag_names { 11023932 4091: // Get or create tag 11033933 4092: let tag_id: i64String = match conn.query_row( 11043934 4093: "SELECT id FROM tags WHERE name = ?", 11053935 4094: params![tag_name], 11063936 4095: |row| row.get(0), 11073937 4096: ) { 11083938 4097: Ok(id) => { 11093939 4098: // Update existing tag stats 11103940 4099: let frequency: u32 = conn 11113941 4100: .query_row("SELECT frequency FROM tags WHERE id = ?", params![&id], |row| row.get(0)) 11123942 4101: .unwrap_or(0); 11133943 4102: 11143944 4103: let new_frequency = frequency + 1; 11153945 4104: let frecency = calculate_frecency(new_frequency, &now); 11163946 4105: 11173947 4106: conn.execute( 11183948 4107: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 11193949 4108: params![new_frequency, &now, frecency, &now, &id], 11203950 4109: ).ok(); 11213951 4110: 11223952 4111: id 11233953 4112: } 11243954 4113: Err(_) => { 11253955 4114: // Create new tag 1126 4115: let new_tag_id = generate_tag_id(); 11273956 4116: let frecency = calculate_frecency(1, &now); 11283957 4117: conn.execute( 11293958 4118: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 11303959 4119: params![&new_tag_id, tag_name, &now, frecency, &now, &now], 11313960 4120: ) 11323961 4121: .map_err(|e| format!("Failed to insert tag: {}", e))?; 11333962 4122: 11343963 4123: conn.last_insert_rowid()new_tag_id 11353964 4124: } 11363965 4125: }; 11373966 4126: 1138 ... 11394429 4589: CREATE INDEX IF NOT EXISTS idx_items_sync_id ON items(sync_id); 11404430 4590: 11414431 4591: CREATE TABLE IF NOT EXISTS tags ( 11424432 4592: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT, 11434433 4593: name TEXT NOT NULL UNIQUE, 11444434 4594: frequency INTEGER NOT NULL DEFAULT 0, 11454435 4595: lastUsed TEXT NOT NULL, 1146 ... 11474440 4600: 11484441 4601: CREATE TABLE IF NOT EXISTS item_tags ( 11494442 4602: item_id TEXT NOT NULL, 11504443 4603: tag_id INTEGERTEXT NOT NULL, 11514444 4604: created_at TEXT NOT NULL, 11524445 4605: PRIMARY KEY (item_id, tag_id), 11534446 4606: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 1154 ... 11554470 4630: continue; 11564471 4631: } 11574472 4632: 11584473 4633: let tag_id: i64String = match conn.query_row( 11594474 4634: "SELECT id FROM tags WHERE name = ?", 11604475 4635: params![&normalized], 11614476 4636: |row| row.get(0), 11624477 4637: ) { 11634478 4638: Ok(existing_id) => existing_id, 11644479 4639: Err(_) => { 1165 4640: let new_tag_id = generate_tag_id(); 11664480 4641: conn.execute( 11674481 4642: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, 10.0, ?, ?)", 11684482 4643: params![&new_tag_id, &normalized, &now, &now, &now], 11694483 4644: ) 11704484 4645: .expect("Failed to insert tag"); 11714485 4646: conn.last_insert_rowid()new_tag_id 11724486 4647: } 11734487 4648: }; 11744488 4649: 1175 ... 11764619 4780: continue; 11774620 4781: } 11784621 4782: 11794622 4783: let tag_id: i64String = match conn.query_row( 11804623 4784: "SELECT id FROM tags WHERE name = ?", 11814624 4785: params![&normalized], 11824625 4786: |row| row.get(0), 11834626 4787: ) { 11844627 4788: Ok(existing_id) => existing_id, 11854628 4789: Err(_) => { 1186 4790: let new_tag_id = generate_tag_id(); 11874629 4791: conn.execute( 11884630 4792: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, 10.0, ?, ?)", 11894631 4793: params![&new_tag_id, &normalized, &now, &now, &now], 11904632 4794: ) 11914633 4795: .expect("Failed to insert tag"); 11924634 4796: conn.last_insert_rowid()new_tag_id 11934635 4797: } 11944636 4798: }; 11954637 4799: 1196 ... 1197Removed regular file docs/editor-remaining-work.md: 1198 1 : # Editor Extension - Remaining Work 1199 2 : 1200 3 : Research note documenting what's implemented and what's missing compared to peek-edit. 1201 4 : 1202 5 : ## Implemented Features 1203 6 : 1204 7 : ### Folditall-Style Folding 1205 8 : - Header folding (all 6 levels) - folds until next same/higher level 1206 9 : - List item folding - nested lists with children are foldable 1207 10 : - Fenced code block folding (``` and ~~~) 1208 11 : - Vim fold commands work from any line within a fold region 1209 12 : 1210 13 : ### Vim Fold Commands 1211 14 : - `za` - toggle fold 1212 15 : - `zc` - close/fold 1213 16 : - `zo` - open/unfold 1214 17 : - `zM` - fold all 1215 18 : - `zR` - unfold all 1216 19 : - `zm` / `zr` - simplified level-based (same as zM/zR) 1217 20 : - `<Space>` - toggle fold (folditall behavior) 1218 21 : 1219 22 : ### Status Line 1220 23 : - Mode indicator: NORMAL (yellow), INSERT (green), VISUAL (purple) 1221 24 : - Cursor position: `Ln X, Col Y` 1222 25 : - Only visible when vim mode is enabled 1223 26 : 1224 27 : ### Three-Panel Layout 1225 28 : - Outline sidebar (left) - TOC from headers 1226 29 : - CodeMirror editor (center) 1227 30 : - Preview sidebar (right) - live markdown rendering 1228 31 : - Resizable panels 1229 32 : - Focus mode (hides sidebars) 1230 33 : 1231 34 : ## Missing Features (from peek-edit) 1232 35 : 1233 36 : ### High Priority - Core Vim 1234 37 : 1235 38 : | Feature | Description | 1236 39 : |---------|-------------| 1237 40 : | Text Objects | `iw`, `aw`, `is`, `as`, `ip`, `ap`, `i"`, `a"`, `i(`, `a(`, `i{`, `a{` | 1238 41 : | Character Search | `f`, `F`, `t`, `T`, `;`, `,` | 1239 42 : | Word Search | `*` (search word under cursor forward), `#` (backward) | 1240 43 : | Replace Mode | `R` (continuous replace until Escape) | 1241 44 : | Bracket Motion | `%` (jump to matching bracket) | 1242 45 : | Paragraph Motion | `{`, `}` (jump between paragraphs) | 1243 46 : | Viewport Motion | `H` (high), `M` (middle), `L` (low of viewport) | 1244 47 : 1245 48 : **Note:** Many of these may already be in `@replit/codemirror-vim` - needs verification. 1246 49 : 1247 50 : ### Medium Priority - Ex Commands 1248 51 : 1249 52 : | Command | Description | 1250 53 : |---------|-------------| 1251 54 : | `:s/pat/repl/g` | Search and replace with flags | 1252 55 : | `:123` | Go to line 123 | 1253 56 : | `:noh` / `:nohlsearch` | Clear search highlighting | 1254 57 : | `:zen` / `:focus` | Enter focus/zen mode | 1255 58 : | `:outline` / `:ol` | Toggle outline sidebar | 1256 59 : | `:preview` / `:pv` | Toggle preview sidebar | 1257 60 : | `:sidebars` / `:sb` | Toggle both sidebars | 1258 61 : | `:narrow` / `:na` | Centered narrow layout mode | 1259 62 : 1260 63 : ### Medium Priority - Editing Commands 1261 64 : 1262 65 : | Command | Description | 1263 66 : |---------|-------------| 1264 67 : | `gq{motion}` | Wrap text at 78 chars, preserve indent | 1265 68 : | `gJ` | Join lines without space | 1266 69 : | `gu{motion}` | Lowercase | 1267 70 : | `gU{motion}` | Uppercase | 1268 71 : | `g~{motion}` | Toggle case | 1269 72 : 1270 73 : ### Low Priority - Advanced 1271 74 : 1272 75 : | Feature | Description | 1273 76 : |---------|-------------| 1274 77 : | Marks | `ma` (set mark), `'a` / `` `a `` (jump to mark) | 1275 78 : | Macros | `qa` (record), `q` (stop), `@a` (play), `@@` (repeat) | 1276 79 : | Visual Block | `<C-v>` column selection | 1277 80 : | Tab Completion | Tab-complete ex commands | 1278 81 : 1279 82 : ## Implementation Notes 1280 83 : 1281 84 : ### @replit/codemirror-vim 1282 85 : 1283 86 : The vim plugin likely already provides many motions and text objects. Before implementing, check: 1284 87 : 1. What motions/text objects are already available 1285 88 : 2. How to add custom ex commands via `Vim.defineEx()` 1286 89 : 3. Whether `Vim.map()` can extend missing features 1287 90 : 1288 91 : ### Ex Command Integration 1289 92 : 1290 93 : To add UI control via ex commands: 1291 94 : ```javascript 1292 95 : Vim.defineEx('zen', '', (cm) => { 1293 96 : // Toggle focus mode 1294 97 : }); 1295 98 : 1296 99 : Vim.defineEx('outline', 'ol', (cm) => { 1297 100 : // Toggle outline sidebar 1298 101 : }); 1299 102 : ``` 1300 103 : 1301 104 : ### Narrow Mode 1302 105 : 1303 106 : Narrow mode centers the editor with max-width constraint. CSS approach: 1304 107 : ```css 1305 108 : .narrow-mode .editor-container { 1306 109 : max-width: 720px; 1307 110 : margin: 0 auto; 1308 111 : } 1309 112 : ``` 1310 113 : 1311 114 : ## Test Coverage 1312 115 : 1313 116 : Current: 33 tests covering: 1314 117 : - Editor setup (3) 1315 118 : - Fold all/unfold all (3) 1316 119 : - Header folding behavior (4) 1317 120 : - Vim fold commands (5) 1318 121 : - Click-to-fold (1) 1319 122 : - List item folding (5) 1320 123 : - Code block folding (2) 1321 124 : - Spacebar toggle (1) 1322 125 : - Fold from any line (2) 1323 126 : - Nested behavior (2) 1324 127 : - Status line (5) 1325 128 : 1326 129 : ## Files 1327 130 : 1328 131 : - `extensions/editor/codemirror.js` - Main editor with folditall algorithm 1329 132 : - `extensions/editor/editor-layout.js` - Three-panel layout 1330 133 : - `extensions/editor/status-line.js` - Vim status bar 1331 134 : - `extensions/editor/outline-sidebar.js` - TOC sidebar 1332 135 : - `extensions/editor/preview-sidebar.js` - Markdown preview 1333 136 : - `tests/editor/editor-folding.spec.ts` - 33 tests 1334 137 : - `tests/editor/test-page.html` - Test harness 1335Modified regular file extensions/editor/codemirror.js: 1336 1 1: /** 1337 2 2: * CodeMirror Editor Module 1338 3 3: * 1339 4 4: * Provides a configured CodeMirror instance for markdown editing 1340 5 5: * with optional vim mode support and folditall-style folding. 1341 6 6: */ 1342 7 7: 1343 8 8: import { EditorState, Compartment } from '@codemirror/state'; 1344 9 9: import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view'; 1345 10 10: import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; 1346 11 11: import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; 1347 12 12: import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput, foldGutter, foldService, codeFolding, foldAll, unfoldAll, foldEffect, unfoldEffect, foldedRanges, foldable } from '@codemirror/language'; 1348 13 13: import { oneDark } from '@codemirror/theme-one-dark'; 1349 14 14: import { vim, Vim, getCM } from '@replit/codemirror-vim'; 1350 15 15: import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; 1351 16 16: 1352 17 17: // Compartments for runtime-reconfigurable extensions 1353 18 18: const vimCompartment = new Compartment(); 1354 19 19: const themeCompartment = new Compartment(); 1355 20 20: 1356 21 : // ============================================================================ 1357 22 : // Folditall Algorithm Helpers 1358 23 : // ============================================================================ 1359 24 : 1360 25 : /** 1361 26 : * Get the header level (1-6) from a line, or 0 if not a header. 1362 27 : */ 1363 28 : function getHeaderLevel(text) { 1364 29 : const match = text.match(/^(#{1,6})\s+/); 1365 30 : return match ? match[1].length : 0; 1366 31 : } 1367 32 : 1368 33 : /** 1369 34 : * Check if a line is a list item (bullet or numbered). 1370 35 : */ 1371 36 : function isListItem(text) { 1372 37 : return /^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text); 1373 38 : } 1374 39 : 1375 40 : /** 1376 41 : * Get the indentation level of a line (number of leading spaces/tabs). 1377 42 : * Tabs count as 2 spaces. 1378 43 : */ 1379 44 : function getIndent(text) { 1380 45 : let indent = 0; 1381 46 : for (const char of text) { 1382 47 : if (char === ' ') indent++; 1383 48 : else if (char === '\t') indent += 2; 1384 49 : else break; 1385 50 : } 1386 51 : return indent; 1387 52 : } 1388 53 : 1389 54 : /** 1390 55 : * Check if a line is blank or whitespace-only. 1391 56 : */ 1392 57 : function isBlankLine(text) { 1393 58 : return /^\s*$/.test(text); 1394 59 : } 1395 60 : 1396 61 : /** 1397 62 : * Check if a line is a fenced code block opener (``` or ~~~). 1398 63 : */ 1399 64 : function isFencedCodeBlockOpener(text) { 1400 65 : return /^(`{3,}|~{3,})/.test(text.trim()); 1401 66 : } 1402 67 : 1403 68 : /** 1404 69 : * Find the closing fence for a fenced code block. 1405 70 : * Returns line number of closing fence, or null if not found. 1406 71 : */ 1407 72 : function findClosingFence(doc, openerLineNum) { 1408 73 : const openerLine = doc.line(openerLineNum); 1409 74 : const openerText = openerLine.text.trim(); 1410 75 : const match = openerText.match(/^(`{3,}|~{3,})/); 1411 76 : if (!match) return null; 1412 77 : 1413 78 : const fenceChar = match[1][0]; 1414 79 : const fenceLen = match[1].length; 1415 80 : const totalLines = doc.lines; 1416 81 : 1417 82 : for (let i = openerLineNum + 1; i <= totalLines; i++) { 1418 83 : const line = doc.line(i); 1419 84 : const trimmed = line.text.trim(); 1420 85 : // Closing fence must be same char and at least same length 1421 86 : const closeMatch = trimmed.match(new RegExp(`^${fenceChar}{${fenceLen},}$`)); 1422 87 : if (closeMatch) { 1423 88 : return i; 1424 89 : } 1425 90 : } 1426 91 : return null; 1427 92 : } 1428 93 : 1429 94 : /** 1430 95 : * Find the next non-blank line number after lineNum. 1431 96 : * Returns null if no non-blank line exists. 1432 97 : */ 1433 98 : function findNextNonBlank(doc, lineNum) { 1434 99 : const totalLines = doc.lines; 1435 100 : for (let i = lineNum + 1; i <= totalLines; i++) { 1436 101 : const line = doc.line(i); 1437 102 : if (!isBlankLine(line.text)) { 1438 103 : return i; 1439 104 : } 1440 105 : } 1441 106 : return null; 1442 107 : } 1443 108 : 1444 109 : /** 1445 110 : * Check if a line can start a fold based on folditall rules: 1446 111 : * - Headers always can start folds 1447 112 : * - List items can start folds (if they have children) 1448 113 : * - Fenced code block openers can start folds 1449 114 : * - Indent-0 lines can start folds (if they have indented children) 1450 115 : */ 1451 116 : function canStartFold(text) { 1452 117 : if (getHeaderLevel(text) > 0) return true; 1453 118 : if (isListItem(text)) return true; 1454 119 : if (isFencedCodeBlockOpener(text)) return true; 1455 120 : if (getIndent(text) === 0 && !isBlankLine(text)) return true; 1456 121 : return false; 1457 122 : } 1458 123 : 1459 124 : /** 1460 125 : * Check if a line has foldable children (more-indented content following it). 1461 126 : */ 1462 127 : function hasFoldableChildren(doc, lineNum) { 1463 128 : const line = doc.line(lineNum); 1464 129 : const text = line.text; 1465 130 : 1466 131 : // Headers always have children (until next same/higher level header) 1467 132 : if (getHeaderLevel(text) > 0) return true; 1468 133 : 1469 134 : // Fenced code blocks have children if there's a closing fence 1470 135 : if (isFencedCodeBlockOpener(text)) { 1471 136 : return findClosingFence(doc, lineNum) !== null; 1472 137 : } 1473 138 : 1474 139 : const currentIndent = getIndent(text); 1475 140 : const nextNonBlankNum = findNextNonBlank(doc, lineNum); 1476 141 : 1477 142 : if (nextNonBlankNum === null) return false; 1478 143 : 1479 144 : const nextLine = doc.line(nextNonBlankNum); 1480 145 : const nextText = nextLine.text; 1481 146 : 1482 147 : // Next line must be more indented (and not a header) 1483 148 : if (getHeaderLevel(nextText) > 0) return false; 1484 149 : 1485 150 : return getIndent(nextText) > currentIndent; 1486 151 : } 1487 152 : 1488 153 : /** 1489 154 : * Find the end of a fold region starting at lineNum. 1490 155 : * For headers: ends at next header of same or higher level. 1491 156 : * For fenced code blocks: ends at the closing fence. 1492 157 : * For list/indent: ends when indentation returns to same or lower level. 1493 158 : */ 1494 159 : function findFoldEnd(doc, lineNum) { 1495 160 : const line = doc.line(lineNum); 1496 161 : const text = line.text; 1497 162 : const totalLines = doc.lines; 1498 163 : const headerLevel = getHeaderLevel(text); 1499 164 : 1500 165 : if (headerLevel > 0) { 1501 166 : // Header fold: ends at next header of same or higher level 1502 167 : for (let i = lineNum + 1; i <= totalLines; i++) { 1503 168 : const checkLine = doc.line(i); 1504 169 : const checkLevel = getHeaderLevel(checkLine.text); 1505 170 : if (checkLevel > 0 && checkLevel <= headerLevel) { 1506 171 : return i - 1; 1507 172 : } 1508 173 : } 1509 174 : return totalLines; 1510 175 : } 1511 176 : 1512 177 : // Fenced code block fold: ends at closing fence 1513 178 : if (isFencedCodeBlockOpener(text)) { 1514 179 : const closingLine = findClosingFence(doc, lineNum); 1515 180 : return closingLine !== null ? closingLine : lineNum; 1516 181 : } 1517 182 : 1518 183 : // List/indent fold: ends when indentation returns to same or lower level 1519 184 : const startIndent = getIndent(text); 1520 185 : let lastContentLine = lineNum; 1521 186 : 1522 187 : for (let i = lineNum + 1; i <= totalLines; i++) { 1523 188 : const checkLine = doc.line(i); 1524 189 : const checkText = checkLine.text; 1525 190 : 1526 191 : // Skip blank lines but track last content 1527 192 : if (isBlankLine(checkText)) continue; 1528 193 : 1529 194 : // Headers break indent folds 1530 195 : if (getHeaderLevel(checkText) > 0) { 1531 196 : return lastContentLine; 1532 197 : } 1533 198 : 1534 199 : const checkIndent = getIndent(checkText); 1535 200 : 1536 201 : // If indent is same or less, fold ends at previous content line 1537 202 : if (checkIndent <= startIndent) { 1538 203 : return lastContentLine; 1539 204 : } 1540 205 : 1541 206 : lastContentLine = i; 1542 207 : } 1543 208 : 1544 209 : return lastContentLine; 1545 210 : } 1546 211 : 1547 212 : /** 1548 213 : * Folditall-style folding: find the fold region containing a line. 1549 214 : * Searches backwards to find the nearest fold-starting line that contains this line. 1550 215 : */ 1551 216 : function findContainingFoldStart(state, lineNum) { 1552 217 : const doc = state.doc; 1553 218 : const currentLine = doc.line(lineNum); 1554 219 : const currentText = currentLine.text; 1555 220 : 1556 221 : // If current line can start a fold and has children, return it 1557 222 : if (canStartFold(currentText) && hasFoldableChildren(doc, lineNum)) { 1558 223 : return currentLine.from; 1559 224 : } 1560 225 : 1561 226 : const currentIndent = getIndent(currentText); 1562 227 : 1563 228 : // Search backwards for a containing fold region 1564 229 : for (let i = lineNum - 1; i >= 1; i--) { 1565 230 : const line = doc.line(i); 1566 231 : const text = line.text; 1567 232 : 1568 233 : // Skip blank lines 1569 234 : if (isBlankLine(text)) continue; 1570 235 : 1571 236 : const lineIndent = getIndent(text); 1572 237 : const headerLevel = getHeaderLevel(text); 1573 238 : 1574 239 : // Headers always contain following content (until next same-level header) 1575 240 : if (headerLevel > 0) { 1576 241 : // Check if this header's fold extends to our line 1577 242 : const foldEnd = findFoldEnd(doc, i); 1578 243 : if (foldEnd >= lineNum) { 1579 244 : return line.from; 1580 245 : } 1581 246 : continue; 1582 247 : } 1583 248 : 1584 249 : // List items or indent-0 lines with less indent could contain us 1585 250 : if (lineIndent < currentIndent && canStartFold(text) && hasFoldableChildren(doc, i)) { 1586 251 : const foldEnd = findFoldEnd(doc, i); 1587 252 : if (foldEnd >= lineNum) { 1588 253 : return line.from; 1589 254 : } 1590 255 : } 1591 256 : } 1592 257 : 1593 258 : return null; 1594 259 : } 1595 260 : 1596 261 : /** 1597 262 : * Check if a position is inside a folded range. 1598 263 : */ 1599 264 : function isPositionFolded(state, pos) { 1600 265 : const folded = foldedRanges(state); 1601 266 : let found = false; 1602 267 : folded.between(0, state.doc.length, (from, to) => { 1603 268 : if (pos >= from && pos <= to) { 1604 269 : found = true; 1605 270 : } 1606 271 : }); 1607 272 : return found; 1608 273 : } 1609 274 : 1610 275 : /** 1611 276 : * Find the fold range at a position (if folded). 1612 277 : */ 1613 278 : function findFoldedRangeAt(state, pos) { 1614 279 : const folded = foldedRanges(state); 1615 280 : let result = null; 1616 281 : folded.between(0, state.doc.length, (from, to) => { 1617 282 : if (pos >= from && pos <= to) { 1618 283 : result = { from, to }; 1619 284 : } 1620 285 : }); 1621 286 : return result; 1622 287 : } 1623 288 : 1624 289 : // Define vim fold commands using folditall-style region finding 1625 290 : Vim.defineAction('foldAll', (cm) => { 1626 291 : foldAll(cm.cm6); 1627 292 : }); 1628 293 : 1629 294 : Vim.defineAction('unfoldAll', (cm) => { 1630 295 : unfoldAll(cm.cm6); 1631 296 : }); 1632 297 : 1633 298 : Vim.defineAction('foldCode', (cm) => { 1634 299 : const view = cm.cm6; 1635 300 : const pos = view.state.selection.main.head; 1636 301 : const lineNum = view.state.doc.lineAt(pos).number; 1637 302 : 1638 303 : // Find the containing fold region's start 1639 304 : const foldStart = findContainingFoldStart(view.state, lineNum); 1640 305 : if (foldStart !== null) { 1641 306 : // Get the foldable range at the fold start 1642 307 : const foldRange = foldable(view.state, foldStart, foldStart); 1643 308 : if (foldRange) { 1644 309 : // Create fold effect 1645 310 : view.dispatch({ 1646 311 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to }) 1647 312 : }); 1648 313 : } 1649 314 : } 1650 315 : }); 1651 316 : 1652 317 : Vim.defineAction('unfoldCode', (cm) => { 1653 318 : const view = cm.cm6; 1654 319 : const pos = view.state.selection.main.head; 1655 320 : const line = view.state.doc.lineAt(pos); 1656 321 : const lineNum = line.number; 1657 322 : 1658 323 : // First check if we're in a folded range (cursor inside fold) 1659 324 : const foldedRange = findFoldedRangeAt(view.state, pos); 1660 325 : if (foldedRange) { 1661 326 : view.dispatch({ 1662 327 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to }) 1663 328 : }); 1664 329 : return; 1665 330 : } 1666 331 : 1667 332 : // Check if there's a fold starting at the end of current line (we're on the fold line) 1668 333 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to); 1669 334 : if (foldAtLineEnd) { 1670 335 : view.dispatch({ 1671 336 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to }) 1672 337 : }); 1673 338 : return; 1674 339 : } 1675 340 : 1676 341 : // Find the containing fold region and try to unfold it 1677 342 : const foldStart = findContainingFoldStart(view.state, lineNum); 1678 343 : if (foldStart !== null) { 1679 344 : const foldStartLine = view.state.doc.lineAt(foldStart); 1680 345 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to); 1681 346 : if (foldRange) { 1682 347 : // Check if this range is folded 1683 348 : const folded = foldedRanges(view.state); 1684 349 : let isFolded = false; 1685 350 : folded.between(foldRange.from, foldRange.to, (from, to) => { 1686 351 : if (from === foldRange.from) { 1687 352 : isFolded = true; 1688 353 : } 1689 354 : }); 1690 355 : if (isFolded) { 1691 356 : view.dispatch({ 1692 357 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 1693 358 : }); 1694 359 : } 1695 360 : } 1696 361 : } 1697 362 : }); 1698 363 : 1699 364 : Vim.defineAction('toggleFold', (cm) => { 1700 365 : const view = cm.cm6; 1701 366 : const pos = view.state.selection.main.head; 1702 367 : const lineNum = view.state.doc.lineAt(pos).number; 1703 368 : 1704 369 : // Find the containing fold region's start 1705 370 : const foldStart = findContainingFoldStart(view.state, lineNum); 1706 371 : if (foldStart === null) return; 1707 372 : 1708 373 : const line = view.state.doc.lineAt(foldStart); 1709 374 : const foldRange = foldable(view.state, line.from, line.to); 1710 375 : if (!foldRange) return; 1711 376 : 1712 377 : // Check if this range is currently folded 1713 378 : const folded = foldedRanges(view.state); 1714 379 : let isFolded = false; 1715 380 : folded.between(foldRange.from, foldRange.to, (from, to) => { 1716 381 : if (from === foldRange.from) { 1717 382 : isFolded = true; 1718 383 : } 1719 384 : }); 1720 385 : 1721 386 : if (isFolded) { 1722 387 : view.dispatch({ 1723 388 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 1724 389 : }); 1725 390 : } else { 1726 391 : view.dispatch({ 1727 392 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to }) 1728 393 : }); 1729 394 : } 1730 395 : }); 1731 396 : 1732 397 : // Map vim fold commands 1733 398 : Vim.mapCommand('zc', 'action', 'foldCode', {}, { context: 'normal' }); 1734 399 : Vim.mapCommand('zo', 'action', 'unfoldCode', {}, { context: 'normal' }); 1735 400 : Vim.mapCommand('za', 'action', 'toggleFold', {}, { context: 'normal' }); 1736 401 : Vim.mapCommand('zM', 'action', 'foldAll', {}, { context: 'normal' }); 1737 402 : Vim.mapCommand('zR', 'action', 'unfoldAll', {}, { context: 'normal' }); 1738 403 : // zr and zm are level-based - simplified to same as zR/zM for now 1739 404 : Vim.mapCommand('zr', 'action', 'unfoldAll', {}, { context: 'normal' }); 1740 405 : Vim.mapCommand('zm', 'action', 'foldAll', {}, { context: 'normal' }); 1741 406 : // Space toggles fold (like za) - folditall behavior 1742 407 : Vim.mapCommand('<Space>', 'action', 'toggleFold', {}, { context: 'normal' }); 1743 408 : 1744 409 21: /** 1745 410 22: * Create a peek-themed CodeMirror theme using CSS variables 1746 411 23: */ 1747 ... 1748 515 127: }, { dark: true }); 1749 516 128: 1750 517 129: /** 1751 518 : * Folditall-style fold service. 1752 519 : * Handles: 1753 520 : * - Markdown headers (fold to next same/higher level header) 1754 521 : * - List items with children (fold nested content) 1755 522 : * - Indent-0 lines with indented children (code blocks, etc.) 1756 523 : */ 1757 524 : const folditallFoldService = foldService.of((state, lineStart, lineEnd) => { 1758 525 : const doc = state.doc; 1759 526 : const line = doc.lineAt(lineStart); 1760 527 : const text = line.text; 1761 528 : const lineNum = line.number; 1762 529 : 1763 530 : // Skip blank lines 1764 531 : if (isBlankLine(text)) return null; 1765 532 : 1766 533 : // Check if this line can start a fold and has children 1767 534 : if (!canStartFold(text)) return null; 1768 535 : if (!hasFoldableChildren(doc, lineNum)) return null; 1769 536 : 1770 537 : // Find fold end 1771 538 : const endLineNum = findFoldEnd(doc, lineNum); 1772 539 : 1773 540 : // Don't fold if there's nothing to fold 1774 541 : if (endLineNum <= lineNum) return null; 1775 542 : 1776 543 : const endLine = doc.line(endLineNum); 1777 544 : 1778 545 : // Fold from end of starting line to end of last line in section 1779 546 : return { from: line.to, to: endLine.to }; 1780 547 : }); 1781 548 : 1782 549 : /** 1783 550 130: * Create a CodeMirror editor instance 1784 551 131: * @param {Object} options - Configuration options 1785 552 132: * @param {HTMLElement} options.parent - Parent element to mount editor in 1786 553 133: * @param {string} options.content - Initial content 1787 554 134: * @param {boolean} options.vimMode - Enable vim mode 1788 555 135: * @param {boolean} options.showLineNumbers - Show line numbers 1789 556 136: * @param {Function} options.onChange - Callback when content changes 1790 557 : * @param {Function} options.onSelectionChange - Callback when cursor position changes (line, col) 1791 558 : * @param {Function} options.onVimModeChange - Callback when vim mode changes (mode string) 1792 559 137: * @returns {EditorView} - CodeMirror EditorView instance 1793 560 138: */ 1794 561 139: export function createEditor({ parent, content = '', vimMode = false, showLineNumbers = true, onChange, onSelectionChange, onVimModeChange }) { 1795 562 : // Track last known vim mode to detect changes 1796 563 : let lastVimMode = 'normal'; 1797 564 : 1798 565 140: const extensions = [ 1799 566 141: // Core extensions 1800 567 142: history(), 1801 ... 1802 584 159: markdown({ base: markdownLanguage }), 1803 585 160: syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 1804 586 161: 1805 587 : // Folding support (required for vim fold commands) 1806 588 : codeFolding(), 1807 589 : folditallFoldService, 1808 590 : 1809 591 162: // Theming 1810 592 163: themeCompartment.of(peekTheme), 1811 593 164: 1812 594 165: // Vim mode (initially based on setting) 1813 595 166: vimCompartment.of(vimMode ? vim() : []), 1814 596 167: 1815 597 168: // UpdateChange listener for content, selection, and vim mode changes 1816 598 169: EditorView.updateListener.of(update => { 1817 599 : // Content change 1818 600 170: if (update.docChanged && onChange) { 1819 601 171: onChange(update.state.doc.toString()); 1820 602 172: } 1821 603 : 1822 604 : // Selection/cursor change 1823 605 : if (update.selectionSet && onSelectionChange) { 1824 606 : const pos = update.state.selection.main.head; 1825 607 : const line = update.state.doc.lineAt(pos); 1826 608 : const col = pos - line.from + 1; 1827 609 : onSelectionChange(line.number, col); 1828 610 : } 1829 611 : 1830 612 : // Vim mode change detection 1831 613 : if (onVimModeChange && vimMode) { 1832 614 : try { 1833 615 : const cm = getCM(update.view); 1834 616 : if (cm && cm.state && cm.state.vim) { 1835 617 : const vimState = cm.state.vim; 1836 618 : let currentMode = 'normal'; 1837 619 : 1838 620 : if (vimState.insertMode) { 1839 621 : currentMode = 'insert'; 1840 622 : } else if (vimState.visualMode) { 1841 623 : currentMode = vimState.visualLine ? 'visual-line' : 1842 624 : vimState.visualBlock ? 'visual-block' : 'visual'; 1843 625 : } else if (vimState.mode === 'replace') { 1844 626 : currentMode = 'replace'; 1845 627 : } 1846 628 : 1847 629 : if (currentMode !== lastVimMode) { 1848 630 : lastVimMode = currentMode; 1849 631 : onVimModeChange(currentMode); 1850 632 : } 1851 633 : } 1852 634 : } catch (e) { 1853 635 : // Vim not active 1854 636 : } 1855 637 : } 1856 638 173: }), 1857 639 174: ]; 1858 640 175: 1859 ... 1860 653 188: parent, 1861 654 189: }); 1862 655 190: 1863 656 : // Initial position callback 1864 657 : if (onSelectionChange) { 1865 658 : const pos = view.state.selection.main.head; 1866 659 : const line = view.state.doc.lineAt(pos); 1867 660 : const col = pos - line.from + 1; 1868 661 : onSelectionChange(line.number, col); 1869 662 : } 1870 663 : 1871 664 : // Initial vim mode callback 1872 665 : if (onVimModeChange && vimMode) { 1873 666 : onVimModeChange('normal'); 1874 667 : } 1875 668 : 1876 669 191: return view; 1877 670 192: } 1878 671 193: 1879 ... 1880Modified regular file extensions/editor/editor-layout.js: 1881 ... 1882 6 6: 1883 7 7: import { OutlineSidebar } from './outline-sidebar.js'; 1884 8 8: import { PreviewSidebar } from './preview-sidebar.js'; 1885 9 : import { StatusLine } from './status-line.js'; 1886 10 9: import * as CodeMirror from './codemirror.js'; 1887 11 10: 1888 12 11: export class EditorLayout { 1889 13 12: constructor(options) { 1890 14 13: this.container = options.container; 1891 15 14: this.onContentChange = options.onContentChange; 1892 16 : this.onVimModeChange = options.onVimModeChange; 1893 17 15: this.initialContent = options.initialContent || ''; 1894 18 16: this.vimMode = options.vimMode || false; 1895 19 17: 1896 20 18: this.outlineSidebar = null; 1897 21 19: this.previewSidebar = null; 1898 22 : this.statusLine = null; 1899 23 20: this.cmEditor = null; 1900 24 21: this.lastContent = ''; 1901 25 22: this.rafId = null; 1902 ... 1903 54 51: this.cmContainer.className = 'cm-container'; 1904 55 52: this.editorContainer.appendChild(this.cmContainer); 1905 56 53: 1906 57 : // Status line container (below editor, above toolbar) 1907 58 : this.statusLineContainer = document.createElement('div'); 1908 59 : this.statusLineContainer.className = 'status-line-container'; 1909 60 : this.editorContainer.appendChild(this.statusLineContainer); 1910 61 : 1911 62 54: // Toolbar below editor 1912 63 55: this.toolbar = document.createElement('div'); 1913 64 56: this.toolbar.className = 'editor-toolbar'; 1914 65 57: 1915 58: // Vim mode toggle 1916 59: this.vimToggle = document.createElement('label'); 1917 60: this.vimToggle.className = 'vim-toggle'; 1918 61: 1919 62: this.vimCheckbox = document.createElement('input'); 1920 63: this.vimCheckbox.type = 'checkbox'; 1921 64: this.vimCheckbox.checked = this.vimMode; 1922 65: this.vimCheckbox.addEventListener('change', () => this.handleVimToggle()); 1923 66: 1924 67: const vimLabel = document.createElement('span'); 1925 68: vimLabel.textContent = 'Vim'; 1926 69: 1927 70: this.vimToggle.appendChild(this.vimCheckbox); 1928 71: this.vimToggle.appendChild(vimLabel); 1929 72: this.toolbar.appendChild(this.vimToggle); 1930 73: 1931 66 74: // Sidebar toggles 1932 67 75: const sidebarToggles = document.createElement('div'); 1933 68 76: sidebarToggles.className = 'sidebar-toggles'; 1934 ... 1935 104 112: 1936 105 113: this.container.appendChild(this.wrapper); 1937 106 114: 1938 107 : // Initialize status line (only shown when vim mode is enabled) 1939 108 : this.statusLine = new StatusLine({ 1940 109 : container: this.statusLineContainer, 1941 110 : }); 1942 111 : 1943 112 : // Hide status line initially if vim mode is off 1944 113 : if (!this.vimMode) { 1945 114 : this.statusLine.hide(); 1946 115 : } 1947 116 : 1948 117 115: // Initialize CodeMirror 1949 118 116: this.cmEditor = CodeMirror.createEditor({ 1950 119 117: parent: this.cmContainer, 1951 120 118: content: this.initialContent, 1952 121 119: vimMode: this.vimMode, 1953 122 120: showLineNumbers: true, 1954 123 121: onChange: (content) => this.handleContentChange(content), 1955 124 : onSelectionChange: (line, col) => this.handleSelectionChange(line, col), 1956 125 : onVimModeChange: (mode) => this.handleVimModeUpdate(mode), 1957 126 122: }); 1958 127 123: 1959 128 124: // Default sidebars to collapsed 1960 ... 1961 274 270: } 1962 275 271: } 1963 276 272: 1964 277 273: /** 1965 278 : * Update vim mode state (called from setVimMode). 1966 279 : */ 1967 280 273: updateVimModeStatehandleVimToggle(enabled) { 1968 281 274: this.vimMode = enabledthis.vimCheckbox.checked; 1969 282 275: if (this.cmEditor) { 1970 283 276: CodeMirror.setVimMode(this.cmEditor, this.vimMode); 1971 284 277: } 1972 285 : 1973 286 : // Show/hide status line based on vim mode 1974 287 : if (this.statusLine) { 1975 288 : if (this.vimMode) { 1976 289 : this.statusLine.show(); 1977 290 : this.statusLine.updateMode('normal'); 1978 291 : } else { 1979 292 : this.statusLine.hide(); 1980 293 : } 1981 294 : } 1982 295 : 1983 296 : // Notify parent of vim mode change for persistence 1984 297 : if (this.onVimModeChange) { 1985 298 : this.onVimModeChange(this.vimMode); 1986 299 : } 1987 300 : } 1988 301 : 1989 302 : /** 1990 303 : * Handle cursor position changes. 1991 304 : */ 1992 305 : handleSelectionChange(line, col) { 1993 306 : if (this.statusLine) { 1994 307 : this.statusLine.updatePosition(line, col); 1995 308 : } 1996 309 : } 1997 310 : 1998 311 : /** 1999 312 : * Handle vim mode state changes (normal, insert, visual, etc.) 2000 313 : */ 2001 314 : handleVimModeUpdate(mode) { 2002 315 : if (this.statusLine && this.vimMode) { 2003 316 : this.statusLine.updateMode(mode); 2004 317 : } 2005 318 278: } 2006 319 279: 2007 320 280: jumpToHeader(header) { 2008 ... 2009 448 408: * Set vim mode. 2010 449 409: */ 2011 450 410: setVimMode(enabled) { 2012 451 411: this.updateVimModeState(vimMode = enabled; 2013 412: this.vimCheckbox.checked = enabled; 2014 413: if (this.cmEditor) { 2015 451 414: CodeMirror.setVimMode(this.cmEditor, enabled); 2016 415: } 2017 452 416: } 2018 453 417: 2019 454 418: /** 2020 ... 2021 483 447: this.cmEditor = null; 2022 484 448: } 2023 485 449: 2024 486 : this.statusLine?.destroy(); 2025 487 450: this.outlineSidebar?.destroy(); 2026 488 451: this.previewSidebar?.destroy(); 2027 489 452: this.wrapper.remove(); 2028 ... 2029Modified regular file extensions/editor/home.js: 2030 ... 2031 17 17: // Editor layout instance 2032 18 18: let editorLayout = null; 2033 19 19: 2034 20 20: // Settings keystore for vim mode preference 2035 21: let settingsStore = null; 2036 21 22: const SETTINGS_KEY = 'editor.vimMode'; 2037 22 23: 2038 23 24: /** 2039 24 : * Sample markdown content for testing folding features. 2040 25 : * Tests: headers (6 levels), nested lists, code blocks. 2041 25: * Sample markdown content for new documents 2042 26 26: */ 2043 27 : const SAMPLE_CONTENT = `# Level 1 Header - Main Document 2044 28 : 2045 29 : This content is under a level 1 header. 2046 30 : 2047 31 : ## Level 2 - Features 2048 32 : 2049 33 : Content under level 2. 2050 34 : 2051 35 : ### Level 3 - Folding Types 2052 36 : 2053 37 : We support multiple folding types. 2054 38 : 2055 39 : #### Level 4 - Header Folding 2056 40 : 2057 41 : Headers fold everything until the next header of same or higher level. 2058 42 : 2059 43 : ##### Level 5 - Deep Nesting 2060 44 : 2061 45 : This is deeply nested content. 2062 46 : 2063 47 : ###### Level 6 - Maximum Depth 2064 48 : 2065 49 : This is the deepest header level supported. 2066 50 : 2067 51 : Back to level 5 content. 2068 52 : 2069 53 : ##### Level 5 - Another Section 2070 54 : 2071 55 : Another level 5 section. 2072 56 : 2073 57 : #### Level 4 - List Folding 2074 58 : 2075 59 : Lists with children are foldable: 2076 60 : 2077 61 : - Parent item with children 2078 62 : - Child item one 2079 63 : - Child item two 2080 64 : - Grandchild item 2081 65 : - Another grandchild 2082 66 : - Child item three 2083 67 : - Simple item (no children) 2084 68 : - Another parent 2085 69 : - Single child 2086 70 : 2087 71 : Numbered lists also fold: 2088 72 : 2089 73 : 1. First parent 2090 74 : 1. Sub-item one 2091 75 : 2. Sub-item two 2092 76 : 2. Second parent 2093 77 : - Mixed child 2094 78 : - Another mixed 2095 79 : 2096 80 : #### Level 4 - Code Block Folding 2097 81 : 2098 82 : Top-level code blocks fold their contents: 2099 27: const SAMPLE_CONTENT = `# Welcome to the Editor 2100 28: 2101 29: This is a **markdown editor** with live preview and outline navigation. 2102 30: 2103 31: ## Features 2104 32: 2105 33: - **Outline sidebar** - Click headers to jump to them 2106 34: - **Live preview** - See rendered markdown as you type 2107 35: - **Vim mode** - Toggle vim keybindings in the toolbar 2108 36: - **Focus mode** - Distraction-free editing 2109 37: 2110 38: ## Getting Started 2111 39: 2112 40: Start typing to edit this document. Use the toolbar buttons to toggle sidebars. 2113 41: 2114 42: ### Keyboard Shortcuts 2115 43: 2116 44: - \`Cmd+Shift+O\` - Toggle outline sidebar 2117 45: - \`Cmd+Shift+P\` - Toggle preview sidebar 2118 46: - \`Escape\` - Exit focus mode 2119 47: 2120 48: ## Code Example 2121 83 49: 2122 84 50: \`\`\`javascript 2123 85 51: function examplegreet(name) { 2124 86 52: return if (condition) { 2125 87 : doSomething(); 2126 88 : } 2127 89 : return result; 2128 90 : } 2129 91 : 2130 92 : class MyClass { 2131 93 : constructor() { 2132 94 : this.x = 1; 2133 95 : } 2134 96 : 2135 97 : method() { 2136 98 : return this.x; 2137 99 52: }\`Hello, \${name}!\`; 2138 100 53: } 2139 101 54: \`\`\` 2140 102 55: 2141 103 : ### Level 3 - Vim Fold Commands 2142 104 : 2143 105 : Test these vim commands (enable vim mode first): 2144 106 : 2145 107 : | Command | Action | 2146 108 : |---------|--------| 2147 109 : | \`za\` | Toggle fold under cursor | 2148 110 : | \`zo\` | Open fold under cursor | 2149 111 : | \`zc\` | Close fold under cursor | 2150 112 : | \`zR\` | Open all folds | 2151 113 : | \`zM\` | Close all folds | 2152 114 : | \`zr\` | Reduce folding (open one level) | 2153 115 : | \`zm\` | More folding (close one level) | 2154 116 : 2155 117 : ## Level 2 - Another Top Section 2156 118 : 2157 119 : This tests that level 2 properly ends the previous level 2 section. 2158 120 : 2159 121 : ### Level 3 - Final Nested 2160 122 : 2161 123 : Final nested content. 2162 124 : 2163 125 : ## Level 2 - Conclusion 2164 126 : 2165 127 : End of test document. 2166 56: ## Lists 2167 57: 2168 58: - Item one 2169 59: - Item two 2170 60: - Item three 2171 61: 2172 62: 1. First 2173 63: 2. Second 2174 64: 3. Third 2175 65: 2176 66: ## Links and Images 2177 67: 2178 68: [Visit GitHub](https://github.com) 2179 69: 2180 70: > This is a blockquote. 2181 71: > It can span multiple lines. 2182 72: 2183 73: --- 2184 74: 2185 75: *Happy writing!* 2186 128 76: `; 2187 129 77: 2188 130 78: /** 2189 ... 2190 139 87: return; 2191 140 88: } 2192 141 89: 2193 142 90: // Load vim mode preference from localStorage 2194 143 91: let vimMode = false; 2195 144 : try { 2196 145 : vimMode = localStorage.getItem(SETTINGS_KEY) === 'true'; 2197 146 : debug && console.log('[editor] Loaded vimMode setting:', vimMode); 2198 147 : } catch (err) { 2199 148 : debug && console.log('[editor] Failed to load settings:', err); 2200 92: if (api?.utils?.createDatastoreStore) { 2201 93: try { 2202 94: settingsStore = await api.utils.createDatastoreStore('editor', { vimMode: false }); 2203 95: vimMode = settingsStore.get(SETTINGS_KEY) || false; 2204 96: debug && console.log('[editor] Loaded vimMode setting:', vimMode); 2205 97: } catch (err) { 2206 98: debug && console.log('[editor] Failed to load settings:', err); 2207 99: } 2208 149 100: } 2209 150 101: 2210 151 102: // Check URL params for content or file path 2211 ... 2212 178 129: initialContent, 2213 179 130: vimMode, 2214 180 131: onContentChange: handleContentChange, 2215 181 : onVimModeChange: handleVimModeChange, 2216 182 132: }); 2217 183 133: 2218 184 134: // Set up escape handler 2219 ... 2220 192 142: }); 2221 193 143: } 2222 194 144: 2223 145: // Listen for vim mode changes to persist 2224 146: const vimCheckbox = document.querySelector('.vim-toggle input'); 2225 147: if (vimCheckbox) { 2226 148: vimCheckbox.addEventListener('change', async () => { 2227 149: const enabled = vimCheckbox.checked; 2228 150: if (settingsStore) { 2229 151: try { 2230 152: await settingsStore.set(SETTINGS_KEY, enabled); 2231 153: debug && console.log('[editor] Saved vimMode setting:', enabled); 2232 154: } catch (err) { 2233 155: debug && console.log('[editor] Failed to save vimMode setting:', err); 2234 156: } 2235 157: } 2236 158: }); 2237 159: } 2238 160: 2239 195 161: debug && console.log('[editor] Editor initialized'); 2240 196 162: }; 2241 197 163: 2242 ... 2243 206 172: }; 2244 207 173: 2245 208 174: /** 2246 209 : * Handle vim mode changes - persist to localStorage 2247 210 : */ 2248 211 : const handleVimModeChange = (enabled) => { 2249 212 : try { 2250 213 : localStorage.setItem(SETTINGS_KEY, enabled ? 'true' : 'false'); 2251 214 : console.log('[editor] Saved vimMode setting:', enabled); 2252 215 : } catch (err) { 2253 216 : console.error('[editor] Failed to save vimMode setting:', err); 2254 217 : } 2255 218 : }; 2256 219 : 2257 220 : /** 2258 221 175: * Clean up on unload 2259 222 176: */ 2260 223 177: const cleanup = () => { 2261 ... 2262Removed regular file extensions/editor/status-line.js: 2263 1 : /** 2264 2 : * Status Line - Vim-style status bar for CodeMirror editor. 2265 3 : * 2266 4 : * Displays: 2267 5 : * - Mode indicator (NORMAL, INSERT, VISUAL, V-LINE) 2268 6 : * - Cursor position (Ln X, Col Y) 2269 7 : * - Temporary messages 2270 8 : */ 2271 9 : 2272 10 : export class StatusLine { 2273 11 : constructor(options = {}) { 2274 12 : this.container = options.container; 2275 13 : this.currentMode = 'normal'; 2276 14 : this.messageTimeout = null; 2277 15 : this.originalModeText = ''; 2278 16 : 2279 17 : this.init(); 2280 18 : } 2281 19 : 2282 20 : init() { 2283 21 : // Create status line container 2284 22 : this.element = document.createElement('div'); 2285 23 : this.element.className = 'vim-status-line'; 2286 24 : this.element.style.cssText = ` 2287 25 : display: flex; 2288 26 : justify-content: space-between; 2289 27 : align-items: center; 2290 28 : padding: 4px 12px; 2291 29 : background: var(--base00); 2292 30 : border-top: 1px solid var(--base02); 2293 31 : min-height: 22px; 2294 32 : font-family: var(--theme-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace); 2295 33 : font-size: 12px; 2296 34 : `; 2297 35 : 2298 36 : // Mode indicator (left side) 2299 37 : this.modeIndicator = document.createElement('span'); 2300 38 : this.modeIndicator.className = 'vim-mode-indicator'; 2301 39 : this.modeIndicator.style.cssText = ` 2302 40 : color: var(--base0A); 2303 41 : font-weight: 600; 2304 42 : `; 2305 43 : this.modeIndicator.textContent = 'NORMAL'; 2306 44 : 2307 45 : // Position info (right side) 2308 46 : this.positionInfo = document.createElement('span'); 2309 47 : this.positionInfo.className = 'vim-position-info'; 2310 48 : this.positionInfo.style.cssText = ` 2311 49 : color: var(--base04); 2312 50 : `; 2313 51 : this.positionInfo.textContent = 'Ln 1, Col 1'; 2314 52 : 2315 53 : this.element.appendChild(this.modeIndicator); 2316 54 : this.element.appendChild(this.positionInfo); 2317 55 : 2318 56 : if (this.container) { 2319 57 : this.container.appendChild(this.element); 2320 58 : } 2321 59 : } 2322 60 : 2323 61 : /** 2324 62 : * Update the mode display. 2325 63 : * @param {string} mode - The vim mode ('normal', 'insert', 'visual', 'visual-line', 'replace') 2326 64 : */ 2327 65 : updateMode(mode) { 2328 66 : this.currentMode = mode; 2329 67 : 2330 68 : const modeLabels = { 2331 69 : 'normal': 'NORMAL', 2332 70 : 'insert': '-- INSERT --', 2333 71 : 'visual': '-- VISUAL --', 2334 72 : 'visual-line': '-- V-LINE --', 2335 73 : 'visual-block': '-- V-BLOCK --', 2336 74 : 'replace': '-- REPLACE --', 2337 75 : }; 2338 76 : 2339 77 : const modeColors = { 2340 78 : 'normal': 'var(--base0A)', // Yellow 2341 79 : 'insert': 'var(--base0B)', // Green 2342 80 : 'visual': 'var(--base0E)', // Purple 2343 81 : 'visual-line': 'var(--base0E)', // Purple 2344 82 : 'visual-block': 'var(--base0E)', // Purple 2345 83 : 'replace': 'var(--base08)', // Red 2346 84 : }; 2347 85 : 2348 86 : const label = modeLabels[mode] || mode.toUpperCase(); 2349 87 : const color = modeColors[mode] || 'var(--base05)'; 2350 88 : 2351 89 : this.modeIndicator.textContent = label; 2352 90 : this.modeIndicator.style.color = color; 2353 91 : this.originalModeText = label; 2354 92 : } 2355 93 : 2356 94 : /** 2357 95 : * Update the cursor position display. 2358 96 : * @param {number} line - Current line number (1-indexed) 2359 97 : * @param {number} col - Current column number (1-indexed) 2360 98 : */ 2361 99 : updatePosition(line, col) { 2362 100 : this.positionInfo.textContent = `Ln ${line}, Col ${col}`; 2363 101 : } 2364 102 : 2365 103 : /** 2366 104 : * Show a temporary message in place of the mode indicator. 2367 105 : * @param {string} message - Message to display 2368 106 : * @param {number} duration - Duration in ms (0 = permanent until next update) 2369 107 : */ 2370 108 : showMessage(message, duration = 3000) { 2371 109 : // Clear any existing timeout 2372 110 : if (this.messageTimeout) { 2373 111 : clearTimeout(this.messageTimeout); 2374 112 : this.messageTimeout = null; 2375 113 : } 2376 114 : 2377 115 : // Store original and show message 2378 116 : const originalText = this.modeIndicator.textContent; 2379 117 : const originalColor = this.modeIndicator.style.color; 2380 118 : 2381 119 : this.modeIndicator.textContent = message; 2382 120 : this.modeIndicator.style.color = 'var(--base04)'; 2383 121 : 2384 122 : if (duration > 0) { 2385 123 : this.messageTimeout = setTimeout(() => { 2386 124 : this.modeIndicator.textContent = this.originalModeText || originalText; 2387 125 : this.modeIndicator.style.color = originalColor; 2388 126 : this.messageTimeout = null; 2389 127 : }, duration); 2390 128 : } 2391 129 : } 2392 130 : 2393 131 : /** 2394 132 : * Show the status line. 2395 133 : */ 2396 134 : show() { 2397 135 : this.element.style.display = 'flex'; 2398 136 : } 2399 137 : 2400 138 : /** 2401 139 : * Hide the status line. 2402 140 : */ 2403 141 : hide() { 2404 142 : this.element.style.display = 'none'; 2405 143 : } 2406 144 : 2407 145 : /** 2408 146 : * Get the status line element. 2409 147 : */ 2410 148 : getElement() { 2411 149 : return this.element; 2412 150 : } 2413 151 : 2414 152 : /** 2415 153 : * Destroy the status line. 2416 154 : */ 2417 155 : destroy() { 2418 156 : if (this.messageTimeout) { 2419 157 : clearTimeout(this.messageTimeout); 2420 158 : } 2421 159 : this.element.remove(); 2422 160 : } 2423 161 : } 2424Removed regular file notes/izui-model.md: 2425 1 : # IZUI: Inverted Zooming User Interface 2426 2 : 2427 3 : Research note documenting the IZUI navigation model for Peek. 2428 4 : 2429 5 : ## Overview 2430 6 : 2431 7 : IZUI (Inverted Zooming User Interface) inverts the traditional ZUI model: 2432 8 : 2433 9 : | ZUI | IZUI | 2434 10 : |-----|------| 2435 11 : | Start from known root | Enter at any point | 2436 12 : | Navigate by zooming in | Navigate by zooming out (ESC) | 2437 13 : | Single entry point | Multiple entry points | 2438 14 : | Navigate forward to destination | Navigate backward to familiar ground | 2439 15 : 2440 16 : ## Core Principles 2441 17 : 2442 18 : 1. **Unbounded Entry**: Users can enter at any point via global hotkeys, commands, links, or extension actions. 2443 19 : 2444 20 : 2. **Predictable Exit**: ESC always walks backward through navigation until reaching the entry point. 2445 21 : 2446 22 : 3. **Context Preservation**: Each window preserves its internal state for return visits. 2447 23 : 2448 24 : ## Implementation Status 2449 25 : 2450 26 : ### Current: Minimal Parent Tracking (v1) 2451 27 : 2452 28 : The initial implementation uses simple parent-child relationships: 2453 29 : - Windows track their opener via `source` address 2454 30 : - On ESC/close, focus restores to parent window 2455 31 : - No central stack data structure needed 2456 32 : 2457 33 : See `app/izui.js` for implementation. 2458 34 : 2459 35 : ### Future: Full Stack Model (v2) 2460 36 : 2461 37 : For more complex navigation patterns, a full stack model may be needed. 2462 38 : 2463 39 : #### Stack Structure 2464 40 : 2465 41 : ``` 2466 42 : IzuiStack { 2467 43 : id: string // Unique stack identifier 2468 44 : entries: StackEntry[] // Ordered list, index 0 = oldest 2469 45 : entryMode: 'active' | 'transient' 2470 46 : createdAt: number 2471 47 : } 2472 48 : 2473 49 : StackEntry { 2474 50 : windowId: number 2475 51 : address: string 2476 52 : params: object 2477 53 : pushedAt: number 2478 54 : } 2479 55 : ``` 2480 56 : 2481 57 : #### Entry Modes 2482 58 : 2483 59 : **Active Mode**: Peek was already focused when stack was created. 2484 60 : - ESC navigates internal state before closing 2485 61 : - Designed for deep navigation workflows 2486 62 : 2487 63 : **Transient Mode**: Peek was invoked from another app via global hotkey. 2488 64 : - ESC closes immediately to return user to previous app 2489 65 : - Minimal friction for quick peek-and-return workflows 2490 66 : 2491 67 : #### Stack Operations 2492 68 : 2493 69 : - **Push**: Window opened from current context 2494 70 : - **Pop**: ESC pressed, window at root state 2495 71 : - **Destroy**: Last entry popped 2496 72 : 2497 73 : ## Implementation Options Evaluated 2498 74 : 2499 75 : ### Option A: Client-side only (pubsub coordination) 2500 76 : 2501 77 : Stack state in each renderer, coordinated via pubsub. 2502 78 : 2503 79 : **Pros**: Backend-agnostic 2504 80 : **Cons**: Eventually consistent, race conditions, lost state if coordinator closes 2505 81 : 2506 82 : ### Option B: Background window coordinator 2507 83 : 2508 84 : Stack state in `peek://app/background.html`, queried via pubsub. 2509 85 : 2510 86 : **Pros**: Single source of truth, backend-agnostic 2511 87 : **Cons**: Latency on every query, complex message passing 2512 88 : 2513 89 : ### Option C: Duplicate backend implementation 2514 90 : 2515 91 : Implement in both Electron (TypeScript) and Tauri (Rust). 2516 92 : 2517 93 : **Pros**: Synchronous, no race conditions 2518 94 : **Cons**: Duplicate code, maintenance burden 2519 95 : 2520 96 : ### Option D: Minimal parent tracking (SELECTED) 2521 97 : 2522 98 : Use existing `source` tracking, focus parent on close. 2523 99 : 2524 100 : **Pros**: Simple, backend-agnostic, no new data structures 2525 101 : **Cons**: Limited to linear parent-child, no branching stacks 2526 102 : 2527 103 : ## Security Considerations 2528 104 : 2529 105 : ### Cross-Origin Navigation 2530 106 : 2531 107 : | From | To | Behavior | 2532 108 : |------|----|----------| 2533 109 : | peek://app/* | peek://app/* | Same stack | 2534 110 : | peek://app/* | peek://ext/* | Same stack | 2535 111 : | peek://ext/* | peek://app/* | Same stack | 2536 112 : | peek://* | https://* | New stack (isolated) | 2537 113 : | https://* | peek://* | Blocked | 2538 114 : | https://* | https://* | New stack (isolated) | 2539 115 : 2540 116 : External URLs should start isolated stacks to prevent untrusted content from manipulating Peek's navigation. 2541 117 : 2542 118 : ## History Integration (Future) 2543 119 : 2544 120 : Stack operations could optionally record to history: 2545 121 : 2546 122 : ```javascript 2547 123 : trackNavigation(address, { 2548 124 : source: 'izui', 2549 125 : stackId: stack.id, 2550 126 : action: 'push' | 'pop' 2551 127 : }); 2552 128 : ``` 2553 129 : 2554 130 : ## Visual Indicators (Future) 2555 131 : 2556 132 : Possible UI for stack depth: 2557 133 : - Breadcrumb trail 2558 134 : - Depth counter badge 2559 135 : - Mini-map of stack 2560 136 : 2561 137 : ## Open Questions 2562 138 : 2563 139 : 1. **Stack Branching**: Should stacks support multiple children from one parent? 2564 140 : 2. **Stack Persistence**: Should stacks survive app restart for session restore? 2565 141 : 3. **Multiple Stacks**: How should concurrent stacks interact? 2566 142 : 2567 143 : ## References 2568 144 : 2569 145 : - `notes/escape-navigation.md` - ESC behavior design 2570 146 : - `notes/design-vision.md` - Original IZUI concept 2571 147 : - `app/izui.js` - Current implementation 2572Modified regular file package.json: 2573 ... 2574 108 108: "test:e2e:server": "./scripts/e2e-server.sh", 2575 109 109: "interactive-test:e2e:full-sync": "./scripts/e2e-full-sync-test.sh", 2576 110 110: "interactive-test:e2e:full-sync:auto": "./scripts/e2e-full-sync-test.sh --headless --build", 2577 111: "test:e2e:mobile": "LOCAL_IP=localhost ./scripts/e2e-full-sync-test.sh --headless --build", 2578 111 112: "test:mobile": "cd backend/tauri-mobile && npm test", 2579 112 113: "test:extension": "node --test backend/extension/tests/*.test.js", 2580 113 114: "extension:chrome": "backend/extension/scripts/launch-chrome.sh", 2581 ... 2582Modified regular file playwright.config.ts: 2583 ... 2584 31 31: browserName: 'chromium', 2585 32 32: }, 2586 33 33: }, 2587 34 : { 2588 35 : name: 'editor', 2589 36 : testMatch: /editor\/.*\.spec\.ts/, 2590 37 : use: { 2591 38 : // Editor tests run in browser 2592 39 : browserName: 'chromium', 2593 40 : }, 2594 41 : }, 2595 42 34: // Future projects: 2596 43 35: // { name: 'mobile', testMatch: /mobile\/.*\.spec\.ts/ }, 2597 44 36: // { name: 'extension', testMatch: /extension\/.*\.spec\.ts/ }, 2598 ... 2599Modified regular file preload.js: 2600 ... 2601 454 454: // History operations (visits joined with addresses) 2602 455 455: getHistory: (filter = {}) => { 2603 456 456: return ipcRenderer.invoke('datastore-get-history', { filter }); 2604 457 : }, 2605 458 : 2606 459 : // Item visit operations (modern URL history API) 2607 460 : queryItemVisits: (filter = {}) => { 2608 461 : return ipcRenderer.invoke('datastore-query-item-visits', { filter }); 2609 462 : }, 2610 463 : getItemVisits: (itemId, filter = {}) => { 2611 464 : return ipcRenderer.invoke('datastore-get-item-visits', { itemId, filter }); 2612 465 : }, 2613 466 : recordItemVisit: (itemId, options = {}) => { 2614 467 : return ipcRenderer.invoke('datastore-record-item-visit', { itemId, options }); 2615 468 457: } 2616 469 458: }; 2617 470 459: 2618 ... 2619Modified regular file schema/generated/sqlite-full.sql: 2620 1 1: -- Generated by schema/codegen.js 2621 2 2: -- Schema version: 1 2622 3 : -- Generated: 2026-01-30T11:28:43.966Z 2623 3: -- Generated: 2026-01-31T00:08:16.086Z 2624 4 4: -- DO NOT EDIT - regenerate with: yarn schema:codegen 2625 5 5: 2626 6 6: -- Unified content storage - URLs, text notes, tagsets, and images 2627 ... 2628Modified regular file schema/generated/sqlite-sync.sql: 2629 1 1: -- Generated by schema/codegen.js 2630 2 2: -- Schema version: 1 2631 3 : -- Generated: 2026-01-30T11:28:43.966Z 2632 3: -- Generated: 2026-01-31T00:08:16.091Z 2633 4 4: -- DO NOT EDIT - regenerate with: yarn schema:codegen 2634 5 5: 2635 6 6: -- Unified content storage - URLs, text notes, tagsets, and images 2636 ... 2637Modified regular file schema/generated/types.rs: 2638 1 1: // Generated by schema/codegen.js 2639 2 2: // Schema version: 1 2640 3 : // Generated: 2026-01-30T11:28:43.967Z 2641 3: // Generated: 2026-01-31T00:08:16.091Z 2642 4 4: // DO NOT EDIT - regenerate with: yarn schema:codegen 2643 5 5: 2644 6 6: use serde::{Deserialize, Serialize}; 2645 ... 2646Modified regular file schema/generated/types.ts: 2647 1 1: /** 2648 2 2: * Generated by schema/codegen.js 2649 3 3: * Schema version: 1 2650 4 : * Generated: 2026-01-30T11:28:43.966Z 2651 4: * Generated: 2026-01-31T00:08:16.091Z 2652 5 5: * DO NOT EDIT - regenerate with: yarn schema:codegen 2653 6 6: */ 2654 7 7: 2655 ... 2656Modified executable file scripts/e2e-full-sync-test.sh: 2657 ... 2658 412 412: ); 2659 413 413: 2660 414 414: CREATE TABLE IF NOT EXISTS tags ( 2661 415 415: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT, 2662 416 416: name TEXT NOT NULL UNIQUE, 2663 417 417: frequency INTEGER NOT NULL DEFAULT 0, 2664 418 418: lastUsed TEXT NOT NULL, 2665 ... 2666 423 423: 2667 424 424: CREATE TABLE IF NOT EXISTS item_tags ( 2668 425 425: item_id TEXT NOT NULL, 2669 426 426: tag_id INTEGERTEXT NOT NULL, 2670 427 427: created_at TEXT NOT NULL, 2671 428 428: PRIMARY KEY (item_id, tag_id), 2672 429 429: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, 2673 ... 2674 462 462: ('ios-e2e-url-1', 'url', 'https://example.com/ios-origin-1', '', '', '', datetime('now'), datetime('now')), 2675 463 463: ('ios-e2e-note-1', 'text', '', 'Note created on iOS', '', '', datetime('now'), datetime('now')); 2676 464 464: 2677 465 465: -- Tags (TEXT IDs matching desktop/server format: tag_{timestamp}_{random9}) 2678 466 466: INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 2679 467 467: VALUES 2680 468 468: ('tag_e2e_ios_001', 'ios', 1, datetime('now'), 1.0, datetime('now'), datetime('now')), 2681 469 469: ('tag_e2e_ios_002', 'e2e', 1, datetime('now'), 1.0, datetime('now'), datetime('now')), 2682 470 470: ('tag_e2e_ios_003', 'note', 1, datetime('now'), 1.0, datetime('now'), datetime('now')); 2683 471 471: 2684 472 472: -- Tag associations (using TEXT tag_id) 2685 473 473: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-url-1', 1'tag_e2e_ios_001', datetime('now')); 2686 474 474: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-url-1', 2'tag_e2e_ios_002', datetime('now')); 2687 475 475: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-note-1', 1'tag_e2e_ios_001', datetime('now')); 2688 476 476: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-note-1', 3'tag_e2e_ios_003', datetime('now')); 2689 477 477: SQLEOF 2690 478 478: 2691 479 479: IOS_COUNT=$(sqlite3 "$IOS_DB" "SELECT COUNT(*) FROM items WHERE deleted_at IS NULL;") 2692 ... 2693 765 765: VALUES 2694 766 766: ('ios-crossdev-1', 'url', '$CROSS_URL', '', '', '', datetime('now'), datetime('now')); 2695 767 767: 2696 768 768: INSERT OR IGNORE INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 2697 769 769: VALUES ('tag_e2e_crossdev', 'cross-device', 1, datetime('now'), 1.0, datetime('now'), datetime('now')); 2698 770 770: 2699 771 771: INSERT INTO item_tags (item_id, tag_id, created_at) 2700 772 : SELECT 'ios-crossdev-1', id, datetime('now') FROM tags WHERE name = 'cross-device'; 2701 772: VALUES ('ios-crossdev-1', 'tag_e2e_crossdev', datetime('now')); 2702 773 773: SQLEOF 2703 774 774: 2704 775 775: echo " Seeded cross-device URL into iOS" 2705 ... 2706 808 808: INSERT INTO items (id, type, url, content, metadata, sync_source, created_at, updated_at) 2707 809 809: VALUES ('ios-tagset-1', 'tagset', '', '', '', '', datetime('now'), datetime('now')); 2708 810 810: 2709 811 811: INSERT OR IGNORE INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 2710 812 812: VALUES 2711 813 813: ('tag_e2e_shared', 'shared', 1, datetime('now'), 1.0, datetime('now'), datetime('now')), 2712 814 814: ('tag_e2e_tagset', 'tagset', 1, datetime('now'), 1.0, datetime('now'), datetime('now')); 2713 815 815: 2714 816 816: INSERT INTO item_tags (item_id, tag_id, created_at) 2715 817 : SELECT 'ios-tagset-1', id, datetime('now') FROM tags WHERE name = 'shared'; 2716 817: VALUES ('ios-tagset-1', 'tag_e2e_shared', datetime('now')); 2717 818 818: INSERT INTO item_tags (item_id, tag_id, created_at) 2718 819 : SELECT 'ios-tagset-1', id, datetime('now') FROM tags WHERE name = 'tagset'; 2719 819: VALUES ('ios-tagset-1', 'tag_e2e_tagset', datetime('now')); 2720 820 820: SQLEOF 2721 821 821: 2722 822 822: echo " Seeded cross-device tagset into iOS" 2723 ... 27241100 1100: VALUES ('dup-ios-tagset-1', 'tagset', '', '', '', '', datetime('now'), datetime('now', '-1 day')); 27251101 1101: 27261102 1102: INSERT INTO item_tags (item_id, tag_id, created_at) 27271103 : SELECT 'dup-ios-tagset-1', id, datetime('now') FROM tags WHERE name IN ('shared', 'tagset'); 2728 1103: VALUES ('dup-ios-tagset-1', 'tag_e2e_shared', datetime('now')); 2729 1104: INSERT INTO item_tags (item_id, tag_id, created_at) 2730 1105: VALUES ('dup-ios-tagset-1', 'tag_e2e_tagset', datetime('now')); 27311104 1106: SQLEOF 27321105 1107: echo " Seeded 3 duplicates into iOS DB" 27331106 1108: 2734 ... 2735Removed regular file tests/editor/editor-folding.spec.ts: 2736 1 : /** 2737 2 : * Editor Folding Tests 2738 3 : * 2739 4 : * Tests for CodeMirror markdown folding features (folditall-style). 2740 5 : * Tests actual folding BEHAVIOR, not just that commands run without errors. 2741 6 : * 2742 7 : * Run with: 2743 8 : * npx playwright test tests/editor/ --project=editor 2744 9 : */ 2745 10 : 2746 11 : import { test, expect, Page } from '@playwright/test'; 2747 12 : import path from 'path'; 2748 13 : import { fileURLToPath } from 'url'; 2749 14 : import { createServer } from 'http'; 2750 15 : import { readFileSync, existsSync } from 'fs'; 2751 16 : 2752 17 : const __filename = fileURLToPath(import.meta.url); 2753 18 : const __dirname = path.dirname(__filename); 2754 19 : const ROOT = path.join(__dirname, '../..'); 2755 20 : 2756 21 : // Simple static file server 2757 22 : let server: ReturnType<typeof createServer>; 2758 23 : let serverUrl: string; 2759 24 : 2760 25 : const mimeTypes: Record<string, string> = { 2761 26 : '.html': 'text/html', 2762 27 : '.js': 'application/javascript', 2763 28 : '.css': 'text/css', 2764 29 : '.json': 'application/json', 2765 30 : }; 2766 31 : 2767 32 : function startServer(): Promise<string> { 2768 33 : return new Promise((resolve) => { 2769 34 : server = createServer((req, res) => { 2770 35 : let filePath = path.join(ROOT, req.url || '/'); 2771 36 : 2772 37 : if (req.url === '/' || req.url === '/test') { 2773 38 : filePath = path.join(__dirname, 'test-page.html'); 2774 39 : } 2775 40 : 2776 41 : if (req.url?.startsWith('/node_modules/')) { 2777 42 : filePath = path.join(ROOT, req.url); 2778 43 : } 2779 44 : 2780 45 : const ext = path.extname(filePath); 2781 46 : const contentType = mimeTypes[ext] || 'text/plain'; 2782 47 : 2783 48 : try { 2784 49 : if (existsSync(filePath)) { 2785 50 : const content = readFileSync(filePath); 2786 51 : res.writeHead(200, { 'Content-Type': contentType }); 2787 52 : res.end(content); 2788 53 : } else { 2789 54 : res.writeHead(404); 2790 55 : res.end(`Not found: ${filePath}`); 2791 56 : } 2792 57 : } catch (err) { 2793 58 : res.writeHead(500); 2794 59 : res.end(`Error: ${err}`); 2795 60 : } 2796 61 : }); 2797 62 : 2798 63 : server.listen(0, '127.0.0.1', () => { 2799 64 : const addr = server.address(); 2800 65 : if (addr && typeof addr === 'object') { 2801 66 : const url = `http://127.0.0.1:${addr.port}`; 2802 67 : resolve(url); 2803 68 : } 2804 69 : }); 2805 70 : }); 2806 71 : } 2807 72 : 2808 73 : function stopServer() { 2809 74 : if (server) { 2810 75 : server.close(); 2811 76 : } 2812 77 : } 2813 78 : 2814 79 : test.describe('Editor Folding @editor', () => { 2815 80 : let page: Page; 2816 81 : 2817 82 : test.beforeAll(async ({ browser }) => { 2818 83 : serverUrl = await startServer(); 2819 84 : page = await browser.newPage(); 2820 85 : 2821 86 : page.on('console', msg => { 2822 87 : if (msg.type() === 'error') { 2823 88 : console.log('Page error:', msg.text()); 2824 89 : } 2825 90 : }); 2826 91 : 2827 92 : page.on('pageerror', err => { 2828 93 : console.log('Page exception:', err.message); 2829 94 : }); 2830 95 : 2831 96 : await page.goto(`${serverUrl}/test`); 2832 97 : await page.waitForSelector('body[data-ready="true"]', { timeout: 15000 }); 2833 98 : }); 2834 99 : 2835 100 : test.afterAll(async () => { 2836 101 : await page.close(); 2837 102 : stopServer(); 2838 103 : }); 2839 104 : 2840 105 : // ========================================================================== 2841 106 : // Helper: Count fold placeholders (indicates folded content) 2842 107 : // ========================================================================== 2843 108 : 2844 109 : async function getFoldPlaceholderCount(): Promise<number> { 2845 110 : return await page.evaluate(() => { 2846 111 : return document.querySelectorAll('.cm-foldPlaceholder').length; 2847 112 : }); 2848 113 : } 2849 114 : 2850 115 : async function getVisibleLineCount(): Promise<number> { 2851 116 : return await page.evaluate(() => { 2852 117 : // Count visible line elements in the editor 2853 118 : const lines = document.querySelectorAll('.cm-line'); 2854 119 : return lines.length; 2855 120 : }); 2856 121 : } 2857 122 : 2858 123 : // ========================================================================== 2859 124 : // Basic Editor Setup 2860 125 : // ========================================================================== 2861 126 : 2862 127 : test.describe('Editor Setup', () => { 2863 128 : test('editor is initialized with content', async () => { 2864 129 : const content = await page.evaluate(() => { 2865 130 : return window.editorView?.state.doc.toString(); 2866 131 : }); 2867 132 : expect(content).toContain('# Level 1 Header'); 2868 133 : expect(content).toContain('###### Level 6'); 2869 134 : }); 2870 135 : 2871 136 : test('fold gutter is visible', async () => { 2872 137 : const hasFoldGutter = await page.evaluate(() => { 2873 138 : return !!document.querySelector('.cm-foldGutter'); 2874 139 : }); 2875 140 : expect(hasFoldGutter).toBe(true); 2876 141 : }); 2877 142 : 2878 143 : test('no folds initially (no placeholders)', async () => { 2879 144 : await page.evaluate(() => window.unfoldAll()); 2880 145 : await page.waitForTimeout(50); 2881 146 : const count = await getFoldPlaceholderCount(); 2882 147 : expect(count).toBe(0); 2883 148 : }); 2884 149 : }); 2885 150 : 2886 151 : // ========================================================================== 2887 152 : // foldAll / unfoldAll API Functions 2888 153 : // ========================================================================== 2889 154 : 2890 155 : test.describe('Fold All / Unfold All API', () => { 2891 156 : test.beforeEach(async () => { 2892 157 : await page.evaluate(() => window.unfoldAll()); 2893 158 : await page.waitForTimeout(50); 2894 159 : }); 2895 160 : 2896 161 : test('foldAll creates fold placeholders', async () => { 2897 162 : const beforeCount = await getFoldPlaceholderCount(); 2898 163 : expect(beforeCount).toBe(0); 2899 164 : 2900 165 : await page.evaluate(() => window.foldAll()); 2901 166 : await page.waitForTimeout(100); 2902 167 : 2903 168 : const afterCount = await getFoldPlaceholderCount(); 2904 169 : expect(afterCount).toBeGreaterThan(0); 2905 170 : }); 2906 171 : 2907 172 : test('unfoldAll removes all fold placeholders', async () => { 2908 173 : // First fold all 2909 174 : await page.evaluate(() => window.foldAll()); 2910 175 : await page.waitForTimeout(100); 2911 176 : 2912 177 : const foldedCount = await getFoldPlaceholderCount(); 2913 178 : expect(foldedCount).toBeGreaterThan(0); 2914 179 : 2915 180 : // Then unfold all 2916 181 : await page.evaluate(() => window.unfoldAll()); 2917 182 : await page.waitForTimeout(100); 2918 183 : 2919 184 : const unfoldedCount = await getFoldPlaceholderCount(); 2920 185 : expect(unfoldedCount).toBe(0); 2921 186 : }); 2922 187 : 2923 188 : test('foldAll reduces visible line count', async () => { 2924 189 : const beforeLines = await getVisibleLineCount(); 2925 190 : 2926 191 : await page.evaluate(() => window.foldAll()); 2927 192 : await page.waitForTimeout(100); 2928 193 : 2929 194 : const afterLines = await getVisibleLineCount(); 2930 195 : expect(afterLines).toBeLessThan(beforeLines); 2931 196 : }); 2932 197 : }); 2933 198 : 2934 199 : // ========================================================================== 2935 200 : // Header Folding (folditall behavior) 2936 201 : // ========================================================================== 2937 202 : 2938 203 : test.describe('Header Folding Behavior', () => { 2939 204 : test.beforeEach(async () => { 2940 205 : await page.evaluate(() => window.unfoldAll()); 2941 206 : await page.waitForTimeout(50); 2942 207 : }); 2943 208 : 2944 209 : test('level 1 header is foldable', async () => { 2945 210 : const result = await page.evaluate(() => { 2946 211 : const line = window.editorView.state.doc.line(1); 2947 212 : return window.foldable(line.from) !== null; 2948 213 : }); 2949 214 : expect(result).toBe(true); 2950 215 : }); 2951 216 : 2952 217 : test('folding level 1 header hides content until next level 1 or EOF', async () => { 2953 218 : // Fold at line 1 (# Level 1 Header) 2954 219 : await page.evaluate(() => { 2955 220 : window.setCursorLine(1); 2956 221 : window.foldCode(window.editorView.state.selection.main.head); 2957 222 : }); 2958 223 : await page.waitForTimeout(100); 2959 224 : 2960 225 : const placeholders = await getFoldPlaceholderCount(); 2961 226 : expect(placeholders).toBeGreaterThan(0); 2962 227 : }); 2963 228 : 2964 229 : test('level 2 header fold ends at next level 2 or higher', async () => { 2965 230 : // Find line number for "## Level 2 - Features" 2966 231 : const lineNum = await page.evaluate(() => { 2967 232 : const doc = window.editorView.state.doc; 2968 233 : for (let i = 1; i <= doc.lines; i++) { 2969 234 : if (doc.line(i).text.startsWith('## Level 2 - Features')) { 2970 235 : return i; 2971 236 : } 2972 237 : } 2973 238 : return -1; 2974 239 : }); 2975 240 : 2976 241 : expect(lineNum).toBeGreaterThan(0); 2977 242 : 2978 243 : // Get the fold range 2979 244 : const foldRange = await page.evaluate((ln) => { 2980 245 : const doc = window.editorView.state.doc; 2981 246 : const line = doc.line(ln); 2982 247 : const range = window.foldable(line.from); 2983 248 : if (!range) return null; 2984 249 : 2985 250 : // Find what line the fold ends at 2986 251 : const endLine = doc.lineAt(range.to); 2987 252 : return { 2988 253 : startLine: ln, 2989 254 : endLineNum: endLine.number, 2990 255 : endLineText: endLine.text.substring(0, 50), 2991 256 : }; 2992 257 : }, lineNum); 2993 258 : 2994 259 : expect(foldRange).not.toBeNull(); 2995 260 : // The fold should end before the next ## header 2996 261 : expect(foldRange!.endLineNum).toBeGreaterThan(lineNum); 2997 262 : }); 2998 263 : 2999 264 : test('all 6 header levels are foldable', async () => { 3000 265 : const results = await page.evaluate(() => { 3001 266 : const doc = window.editorView.state.doc; 3002 267 : const levels: Record<number, boolean> = {}; 3003 268 : 3004 269 : for (let i = 1; i <= doc.lines; i++) { 3005 270 : const line = doc.line(i); 3006 271 : const match = line.text.match(/^(#{1,6})\s+/); 3007 272 : if (match) { 3008 273 : const level = match[1].length; 3009 274 : if (!levels[level]) { 3010 275 : levels[level] = window.foldable(line.from) !== null; 3011 276 : } 3012 277 : } 3013 278 : } 3014 279 : return levels; 3015 280 : }); 3016 281 : 3017 282 : expect(results[1]).toBe(true); 3018 283 : expect(results[2]).toBe(true); 3019 284 : expect(results[3]).toBe(true); 3020 285 : expect(results[4]).toBe(true); 3021 286 : expect(results[5]).toBe(true); 3022 287 : expect(results[6]).toBe(true); 3023 288 : }); 3024 289 : }); 3025 290 : 3026 291 : // ========================================================================== 3027 292 : // Vim Mode Fold Commands 3028 293 : // ========================================================================== 3029 294 : 3030 295 : test.describe('Vim Fold Commands (actual behavior)', () => { 3031 296 : test.beforeEach(async () => { 3032 297 : await page.evaluate(() => { 3033 298 : window.setVimMode(true); 3034 299 : window.unfoldAll(); 3035 300 : }); 3036 301 : await page.waitForTimeout(100); 3037 302 : }); 3038 303 : 3039 304 : test.afterEach(async () => { 3040 305 : await page.evaluate(() => { 3041 306 : window.setVimMode(false); 3042 307 : window.unfoldAll(); 3043 308 : }); 3044 309 : }); 3045 310 : 3046 311 : test('zM actually folds content (creates placeholders)', async () => { 3047 312 : const beforeCount = await getFoldPlaceholderCount(); 3048 313 : expect(beforeCount).toBe(0); 3049 314 : 3050 315 : // Execute zM via Vim 3051 316 : await page.evaluate(() => { 3052 317 : window.setCursorLine(1); 3053 318 : const view = window.editorView; 3054 319 : view.focus(); 3055 320 : }); 3056 321 : await page.waitForTimeout(50); 3057 322 : 3058 323 : // Type zM 3059 324 : await page.keyboard.press('z'); 3060 325 : await page.keyboard.press('Shift+M'); 3061 326 : await page.waitForTimeout(100); 3062 327 : 3063 328 : const afterCount = await getFoldPlaceholderCount(); 3064 329 : expect(afterCount).toBeGreaterThan(0); 3065 330 : }); 3066 331 : 3067 332 : test('zR actually unfolds content (removes placeholders)', async () => { 3068 333 : // First fold everything 3069 334 : await page.evaluate(() => window.foldAll()); 3070 335 : await page.waitForTimeout(100); 3071 336 : 3072 337 : const foldedCount = await getFoldPlaceholderCount(); 3073 338 : expect(foldedCount).toBeGreaterThan(0); 3074 339 : 3075 340 : // Now use zR to unfold 3076 341 : await page.evaluate(() => { 3077 342 : window.setCursorLine(1); 3078 343 : window.editorView.focus(); 3079 344 : }); 3080 345 : await page.waitForTimeout(50); 3081 346 : 3082 347 : await page.keyboard.press('z'); 3083 348 : await page.keyboard.press('Shift+R'); 3084 349 : await page.waitForTimeout(100); 3085 350 : 3086 351 : const afterCount = await getFoldPlaceholderCount(); 3087 352 : expect(afterCount).toBe(0); 3088 353 : }); 3089 354 : 3090 355 : test('zc folds at cursor position', async () => { 3091 356 : const beforeCount = await getFoldPlaceholderCount(); 3092 357 : expect(beforeCount).toBe(0); 3093 358 : 3094 359 : // Position on a header and fold it 3095 360 : await page.evaluate(() => { 3096 361 : window.setCursorLine(1); // Level 1 header 3097 362 : window.editorView.focus(); 3098 363 : }); 3099 364 : await page.waitForTimeout(50); 3100 365 : 3101 366 : await page.keyboard.press('z'); 3102 367 : await page.keyboard.press('c'); 3103 368 : await page.waitForTimeout(100); 3104 369 : 3105 370 : const afterCount = await getFoldPlaceholderCount(); 3106 371 : expect(afterCount).toBeGreaterThan(0); 3107 372 : }); 3108 373 : 3109 374 : test('zo unfolds at cursor position', async () => { 3110 375 : // First fold at line 1 3111 376 : await page.evaluate(() => { 3112 377 : window.setCursorLine(1); 3113 378 : window.foldCode(window.editorView.state.selection.main.head); 3114 379 : }); 3115 380 : await page.waitForTimeout(100); 3116 381 : 3117 382 : const foldedCount = await getFoldPlaceholderCount(); 3118 383 : expect(foldedCount).toBeGreaterThan(0); 3119 384 : 3120 385 : // Now unfold with zo 3121 386 : await page.evaluate(() => { 3122 387 : window.setCursorLine(1); 3123 388 : window.editorView.focus(); 3124 389 : }); 3125 390 : await page.waitForTimeout(50); 3126 391 : 3127 392 : await page.keyboard.press('z'); 3128 393 : await page.keyboard.press('o'); 3129 394 : await page.waitForTimeout(100); 3130 395 : 3131 396 : const afterCount = await getFoldPlaceholderCount(); 3132 397 : expect(afterCount).toBeLessThan(foldedCount); 3133 398 : }); 3134 399 : 3135 400 : test('za toggles fold (fold then unfold)', async () => { 3136 401 : const initialCount = await getFoldPlaceholderCount(); 3137 402 : expect(initialCount).toBe(0); 3138 403 : 3139 404 : await page.evaluate(() => { 3140 405 : window.setCursorLine(1); 3141 406 : window.editorView.focus(); 3142 407 : }); 3143 408 : await page.waitForTimeout(50); 3144 409 : 3145 410 : // First za should fold 3146 411 : await page.keyboard.press('z'); 3147 412 : await page.keyboard.press('a'); 3148 413 : await page.waitForTimeout(100); 3149 414 : 3150 415 : const afterFirstToggle = await getFoldPlaceholderCount(); 3151 416 : expect(afterFirstToggle).toBeGreaterThan(0); 3152 417 : 3153 418 : // Second za should unfold 3154 419 : await page.keyboard.press('z'); 3155 420 : await page.keyboard.press('a'); 3156 421 : await page.waitForTimeout(100); 3157 422 : 3158 423 : const afterSecondToggle = await getFoldPlaceholderCount(); 3159 424 : expect(afterSecondToggle).toBe(0); 3160 425 : }); 3161 426 : }); 3162 427 : 3163 428 : // ========================================================================== 3164 429 : // Click-to-Fold (gutter interaction) 3165 430 : // ========================================================================== 3166 431 : 3167 432 : test.describe('Click-to-Fold', () => { 3168 433 : test.beforeEach(async () => { 3169 434 : await page.evaluate(() => { 3170 435 : window.setVimMode(false); 3171 436 : window.unfoldAll(); 3172 437 : }); 3173 438 : await page.waitForTimeout(50); 3174 439 : }); 3175 440 : 3176 441 : test('clicking fold gutter creates a fold placeholder', async () => { 3177 442 : const beforeCount = await getFoldPlaceholderCount(); 3178 443 : expect(beforeCount).toBe(0); 3179 444 : 3180 445 : // Click the first fold marker in the gutter 3181 446 : const clicked = await page.evaluate(() => { 3182 447 : const gutterElements = document.querySelectorAll('.cm-foldGutter .cm-gutterElement'); 3183 448 : for (const el of gutterElements) { 3184 449 : // Look for a gutter element that has fold indicator 3185 450 : if (el.textContent && el.textContent.trim() !== '') { 3186 451 : (el as HTMLElement).click(); 3187 452 : return true; 3188 453 : } 3189 454 : } 3190 455 : // Try clicking any gutter element on a header line 3191 456 : const firstGutter = gutterElements[0] as HTMLElement; 3192 457 : if (firstGutter) { 3193 458 : firstGutter.click(); 3194 459 : return true; 3195 460 : } 3196 461 : return false; 3197 462 : }); 3198 463 : 3199 464 : if (clicked) { 3200 465 : await page.waitForTimeout(100); 3201 466 : const afterCount = await getFoldPlaceholderCount(); 3202 467 : // Note: Click behavior may vary, but if it worked there should be a placeholder 3203 468 : // This test validates the mechanism exists 3204 469 : } 3205 470 : 3206 471 : expect(true).toBe(true); // Placeholder test - gutter click mechanism exists 3207 472 : }); 3208 473 : }); 3209 474 : 3210 475 : // ========================================================================== 3211 476 : // List Folding (folditall feature) 3212 477 : // ========================================================================== 3213 478 : 3214 479 : test.describe('List Item Folding', () => { 3215 480 : test.beforeEach(async () => { 3216 481 : await page.evaluate(() => window.unfoldAll()); 3217 482 : await page.waitForTimeout(50); 3218 483 : }); 3219 484 : 3220 485 : test('list item with children is foldable', async () => { 3221 486 : // Find a parent list item (- Parent item with children) 3222 487 : const result = await page.evaluate(() => { 3223 488 : const doc = window.editorView.state.doc; 3224 489 : for (let i = 1; i <= doc.lines; i++) { 3225 490 : const line = doc.line(i); 3226 491 : if (line.text.match(/^- Parent item with children/)) { 3227 492 : const isFoldable = window.foldable(line.from) !== null; 3228 493 : return { lineNum: i, text: line.text, foldable: isFoldable }; 3229 494 : } 3230 495 : } 3231 496 : return null; 3232 497 : }); 3233 498 : 3234 499 : expect(result).not.toBeNull(); 3235 500 : expect(result!.foldable).toBe(true); 3236 501 : }); 3237 502 : 3238 503 : test('nested list items create foldable regions', async () => { 3239 504 : // Find lines with child items 3240 505 : const result = await page.evaluate(() => { 3241 506 : const doc = window.editorView.state.doc; 3242 507 : const listItems: { lineNum: number; text: string; foldable: boolean; indent: number }[] = []; 3243 508 : 3244 509 : for (let i = 1; i <= doc.lines; i++) { 3245 510 : const line = doc.line(i); 3246 511 : const text = line.text; 3247 512 : // Match list items 3248 513 : if (/^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text)) { 3249 514 : const indent = text.match(/^(\s*)/)?.[1].length || 0; 3250 515 : const isFoldable = window.foldable(line.from) !== null; 3251 516 : listItems.push({ lineNum: i, text: text.substring(0, 40), foldable: isFoldable, indent }); 3252 517 : } 3253 518 : } 3254 519 : return listItems; 3255 520 : }); 3256 521 : 3257 522 : expect(result.length).toBeGreaterThan(0); 3258 523 : 3259 524 : // At least some list items with children should be foldable 3260 525 : const foldableItems = result.filter(item => item.foldable); 3261 526 : expect(foldableItems.length).toBeGreaterThan(0); 3262 527 : }); 3263 528 : 3264 529 : test('folding list parent hides children', async () => { 3265 530 : const beforeLines = await getVisibleLineCount(); 3266 531 : 3267 532 : // Find and fold a parent list item 3268 533 : const folded = await page.evaluate(() => { 3269 534 : const doc = window.editorView.state.doc; 3270 535 : for (let i = 1; i <= doc.lines; i++) { 3271 536 : const line = doc.line(i); 3272 537 : if (line.text.match(/^- Parent item with children/)) { 3273 538 : const foldRange = window.foldable(line.from); 3274 539 : if (foldRange) { 3275 540 : window.foldCode(line.from); 3276 541 : return true; 3277 542 : } 3278 543 : } 3279 544 : } 3280 545 : return false; 3281 546 : }); 3282 547 : 3283 548 : expect(folded).toBe(true); 3284 549 : await page.waitForTimeout(100); 3285 550 : 3286 551 : const afterLines = await getVisibleLineCount(); 3287 552 : const placeholders = await getFoldPlaceholderCount(); 3288 553 : 3289 554 : expect(placeholders).toBeGreaterThan(0); 3290 555 : expect(afterLines).toBeLessThan(beforeLines); 3291 556 : }); 3292 557 : 3293 558 : test('deeply nested list items (grandchildren) are hidden when parent folds', async () => { 3294 559 : // Find a grandchild line number 3295 560 : const grandchildLineNum = await page.evaluate(() => { 3296 561 : const doc = window.editorView.state.doc; 3297 562 : for (let i = 1; i <= doc.lines; i++) { 3298 563 : if (doc.line(i).text.includes('Grandchild item')) { 3299 564 : return i; 3300 565 : } 3301 566 : } 3302 567 : return -1; 3303 568 : }); 3304 569 : 3305 570 : expect(grandchildLineNum).toBeGreaterThan(0); 3306 571 : 3307 572 : // Fold the top-level parent list item 3308 573 : await page.evaluate(() => { 3309 574 : const doc = window.editorView.state.doc; 3310 575 : for (let i = 1; i <= doc.lines; i++) { 3311 576 : const line = doc.line(i); 3312 577 : if (line.text.match(/^- Parent item with children/)) { 3313 578 : window.foldCode(line.from); 3314 579 : break; 3315 580 : } 3316 581 : } 3317 582 : }); 3318 583 : await page.waitForTimeout(100); 3319 584 : 3320 585 : const placeholders = await getFoldPlaceholderCount(); 3321 586 : expect(placeholders).toBeGreaterThan(0); 3322 587 : }); 3323 588 : 3324 589 : test('simple list item without children is not foldable', async () => { 3325 590 : const result = await page.evaluate(() => { 3326 591 : const doc = window.editorView.state.doc; 3327 592 : for (let i = 1; i <= doc.lines; i++) { 3328 593 : const line = doc.line(i); 3329 594 : if (line.text === '- Simple item (no children)') { 3330 595 : const isFoldable = window.foldable(line.from) !== null; 3331 596 : return { lineNum: i, foldable: isFoldable }; 3332 597 : } 3333 598 : } 3334 599 : return null; 3335 600 : }); 3336 601 : 3337 602 : expect(result).not.toBeNull(); 3338 603 : expect(result!.foldable).toBe(false); 3339 604 : }); 3340 605 : }); 3341 606 : 3342 607 : // ========================================================================== 3343 608 : // Code Block Folding (folditall feature) 3344 609 : // ========================================================================== 3345 610 : 3346 611 : test.describe('Code Block Folding', () => { 3347 612 : test.beforeEach(async () => { 3348 613 : await page.evaluate(() => window.unfoldAll()); 3349 614 : await page.waitForTimeout(50); 3350 615 : }); 3351 616 : 3352 617 : test('fenced code block opener is foldable', async () => { 3353 618 : const result = await page.evaluate(() => { 3354 619 : const doc = window.editorView.state.doc; 3355 620 : for (let i = 1; i <= doc.lines; i++) { 3356 621 : const line = doc.line(i); 3357 622 : if (line.text.startsWith('```javascript')) { 3358 623 : const isFoldable = window.foldable(line.from) !== null; 3359 624 : return { lineNum: i, foldable: isFoldable }; 3360 625 : } 3361 626 : } 3362 627 : return null; 3363 628 : }); 3364 629 : 3365 630 : expect(result).not.toBeNull(); 3366 631 : expect(result!.foldable).toBe(true); 3367 632 : }); 3368 633 : 3369 634 : test('folding code block hides its contents', async () => { 3370 635 : const beforeLines = await getVisibleLineCount(); 3371 636 : 3372 637 : await page.evaluate(() => { 3373 638 : const doc = window.editorView.state.doc; 3374 639 : for (let i = 1; i <= doc.lines; i++) { 3375 640 : const line = doc.line(i); 3376 641 : if (line.text.startsWith('```javascript')) { 3377 642 : window.foldCode(line.from); 3378 643 : break; 3379 644 : } 3380 645 : } 3381 646 : }); 3382 647 : await page.waitForTimeout(100); 3383 648 : 3384 649 : const afterLines = await getVisibleLineCount(); 3385 650 : const placeholders = await getFoldPlaceholderCount(); 3386 651 : 3387 652 : expect(placeholders).toBeGreaterThan(0); 3388 653 : expect(afterLines).toBeLessThan(beforeLines); 3389 654 : }); 3390 655 : }); 3391 656 : 3392 657 : // ========================================================================== 3393 658 : // Spacebar Toggle (folditall feature) 3394 659 : // ========================================================================== 3395 660 : 3396 661 : test.describe('Spacebar Toggle', () => { 3397 662 : test.beforeEach(async () => { 3398 663 : await page.evaluate(() => { 3399 664 : window.setVimMode(true); 3400 665 : window.unfoldAll(); 3401 666 : }); 3402 667 : await page.waitForTimeout(100); 3403 668 : }); 3404 669 : 3405 670 : test.afterEach(async () => { 3406 671 : await page.evaluate(() => { 3407 672 : window.setVimMode(false); 3408 673 : window.unfoldAll(); 3409 674 : }); 3410 675 : }); 3411 676 : 3412 677 : test('spacebar toggles fold like za', async () => { 3413 678 : const initialCount = await getFoldPlaceholderCount(); 3414 679 : expect(initialCount).toBe(0); 3415 680 : 3416 681 : await page.evaluate(() => { 3417 682 : window.setCursorLine(1); 3418 683 : window.editorView.focus(); 3419 684 : }); 3420 685 : await page.waitForTimeout(50); 3421 686 : 3422 687 : // Press space to fold 3423 688 : await page.keyboard.press('Space'); 3424 689 : await page.waitForTimeout(100); 3425 690 : 3426 691 : const afterFold = await getFoldPlaceholderCount(); 3427 692 : expect(afterFold).toBeGreaterThan(0); 3428 693 : 3429 694 : // Press space again to unfold 3430 695 : await page.keyboard.press('Space'); 3431 696 : await page.waitForTimeout(100); 3432 697 : 3433 698 : const afterUnfold = await getFoldPlaceholderCount(); 3434 699 : expect(afterUnfold).toBe(0); 3435 700 : }); 3436 701 : }); 3437 702 : 3438 703 : // ========================================================================== 3439 704 : // Fold from Any Line Within Region (folditall core feature) 3440 705 : // ========================================================================== 3441 706 : 3442 707 : test.describe('Fold From Any Line In Region', () => { 3443 708 : test.beforeEach(async () => { 3444 709 : await page.evaluate(() => { 3445 710 : window.setVimMode(true); 3446 711 : window.unfoldAll(); 3447 712 : }); 3448 713 : await page.waitForTimeout(100); 3449 714 : }); 3450 715 : 3451 716 : test.afterEach(async () => { 3452 717 : await page.evaluate(() => { 3453 718 : window.setVimMode(false); 3454 719 : window.unfoldAll(); 3455 720 : }); 3456 721 : }); 3457 722 : 3458 723 : test('zc from middle of header section folds the containing header', async () => { 3459 724 : // Find line 3 (content under level 1 header) 3460 725 : const lineNum = 3; 3461 726 : 3462 727 : await page.evaluate((ln) => { 3463 728 : window.setCursorLine(ln); 3464 729 : window.editorView.focus(); 3465 730 : }, lineNum); 3466 731 : await page.waitForTimeout(50); 3467 732 : 3468 733 : const beforeCount = await getFoldPlaceholderCount(); 3469 734 : expect(beforeCount).toBe(0); 3470 735 : 3471 736 : await page.keyboard.press('z'); 3472 737 : await page.keyboard.press('c'); 3473 738 : await page.waitForTimeout(100); 3474 739 : 3475 740 : const afterCount = await getFoldPlaceholderCount(); 3476 741 : expect(afterCount).toBeGreaterThan(0); 3477 742 : }); 3478 743 : 3479 744 : test('za from nested list child toggles parent list fold', async () => { 3480 745 : // Find a child list item line 3481 746 : const childLineNum = await page.evaluate(() => { 3482 747 : const doc = window.editorView.state.doc; 3483 748 : for (let i = 1; i <= doc.lines; i++) { 3484 749 : if (doc.line(i).text.includes('Child item one')) { 3485 750 : return i; 3486 751 : } 3487 752 : } 3488 753 : return -1; 3489 754 : }); 3490 755 : 3491 756 : expect(childLineNum).toBeGreaterThan(0); 3492 757 : 3493 758 : await page.evaluate((ln) => { 3494 759 : window.setCursorLine(ln); 3495 760 : window.editorView.focus(); 3496 761 : }, childLineNum); 3497 762 : await page.waitForTimeout(50); 3498 763 : 3499 764 : // za should fold the containing list parent 3500 765 : await page.keyboard.press('z'); 3501 766 : await page.keyboard.press('a'); 3502 767 : await page.waitForTimeout(100); 3503 768 : 3504 769 : const afterFold = await getFoldPlaceholderCount(); 3505 770 : expect(afterFold).toBeGreaterThan(0); 3506 771 : }); 3507 772 : }); 3508 773 : 3509 774 : // ========================================================================== 3510 775 : // Folditall-Specific: Nested Content Behavior 3511 776 : // ========================================================================== 3512 777 : 3513 778 : test.describe('Folditall Nested Behavior', () => { 3514 779 : test.beforeEach(async () => { 3515 780 : await page.evaluate(() => window.unfoldAll()); 3516 781 : await page.waitForTimeout(50); 3517 782 : }); 3518 783 : 3519 784 : test('folding parent header hides child headers', async () => { 3520 785 : // Fold "## Level 2 - Features" which contains ### Level 3, #### Level 4, etc. 3521 786 : const result = await page.evaluate(() => { 3522 787 : const doc = window.editorView.state.doc; 3523 788 : let level2Line = -1; 3524 789 : 3525 790 : for (let i = 1; i <= doc.lines; i++) { 3526 791 : if (doc.line(i).text.startsWith('## Level 2 - Features')) { 3527 792 : level2Line = i; 3528 793 : break; 3529 794 : } 3530 795 : } 3531 796 : 3532 797 : if (level2Line === -1) return { error: 'Level 2 header not found' }; 3533 798 : 3534 799 : // Get content before folding 3535 800 : const visibleLinesBefore = document.querySelectorAll('.cm-line').length; 3536 801 : 3537 802 : // Fold at level 2 3538 803 : const line = doc.line(level2Line); 3539 804 : window.foldCode(line.from); 3540 805 : 3541 806 : return { 3542 807 : level2Line, 3543 808 : visibleLinesBefore, 3544 809 : }; 3545 810 : }); 3546 811 : 3547 812 : expect(result.level2Line).toBeGreaterThan(0); 3548 813 : 3549 814 : await page.waitForTimeout(100); 3550 815 : 3551 816 : const visibleLinesAfter = await getVisibleLineCount(); 3552 817 : const placeholders = await getFoldPlaceholderCount(); 3553 818 : 3554 819 : // After folding level 2, visible lines should decrease and placeholder should appear 3555 820 : expect(placeholders).toBeGreaterThan(0); 3556 821 : expect(visibleLinesAfter).toBeLessThan(result.visibleLinesBefore!); 3557 822 : }); 3558 823 : 3559 824 : test('deeply nested headers (level 5, 6) are individually foldable', async () => { 3560 825 : const result = await page.evaluate(() => { 3561 826 : const doc = window.editorView.state.doc; 3562 827 : const foldableHeaders: { level: number; line: number; foldable: boolean }[] = []; 3563 828 : 3564 829 : for (let i = 1; i <= doc.lines; i++) { 3565 830 : const lineText = doc.line(i).text; 3566 831 : const match = lineText.match(/^(#{5,6})\s+/); 3567 832 : if (match) { 3568 833 : const level = match[1].length; 3569 834 : const line = doc.line(i); 3570 835 : const isFoldable = window.foldable(line.from) !== null; 3571 836 : foldableHeaders.push({ level, line: i, foldable: isFoldable }); 3572 837 : } 3573 838 : } 3574 839 : 3575 840 : return foldableHeaders; 3576 841 : }); 3577 842 : 3578 843 : // Should have found level 5 and 6 headers 3579 844 : const level5 = result.filter(h => h.level === 5); 3580 845 : const level6 = result.filter(h => h.level === 6); 3581 846 : 3582 847 : expect(level5.length).toBeGreaterThan(0); 3583 848 : expect(level6.length).toBeGreaterThan(0); 3584 849 : 3585 850 : // They should all be foldable 3586 851 : for (const h of result) { 3587 852 : expect(h.foldable).toBe(true); 3588 853 : } 3589 854 : }); 3590 855 : }); 3591 856 : 3592 857 : // ========================================================================== 3593 858 : // Status Line (vim-style status bar) 3594 859 : // ========================================================================== 3595 860 : 3596 861 : test.describe('Status Line', () => { 3597 862 : test.beforeEach(async () => { 3598 863 : await page.evaluate(() => { 3599 864 : window.setVimMode(true); 3600 865 : window.unfoldAll(); 3601 866 : }); 3602 867 : await page.waitForTimeout(100); 3603 868 : }); 3604 869 : 3605 870 : test.afterEach(async () => { 3606 871 : await page.evaluate(() => { 3607 872 : window.setVimMode(false); 3608 873 : }); 3609 874 : }); 3610 875 : 3611 876 : test('status line shows position info', async () => { 3612 877 : // Move cursor to line 5 3613 878 : await page.evaluate(() => { 3614 879 : window.setCursorLine(5); 3615 880 : }); 3616 881 : await page.waitForTimeout(100); 3617 882 : 3618 883 : const positionText = await page.evaluate(() => { 3619 884 : const posInfo = document.querySelector('.vim-position-info'); 3620 885 : return posInfo?.textContent || ''; 3621 886 : }); 3622 887 : 3623 888 : expect(positionText).toContain('Ln 5'); 3624 889 : expect(positionText).toContain('Col'); 3625 890 : }); 3626 891 : 3627 892 : test('status line updates on cursor movement', async () => { 3628 893 : // Move to line 1 3629 894 : await page.evaluate(() => { 3630 895 : window.setCursorLine(1); 3631 896 : }); 3632 897 : await page.waitForTimeout(100); 3633 898 : 3634 899 : const pos1 = await page.evaluate(() => { 3635 900 : return document.querySelector('.vim-position-info')?.textContent || ''; 3636 901 : }); 3637 902 : expect(pos1).toContain('Ln 1'); 3638 903 : 3639 904 : // Move to line 10 3640 905 : await page.evaluate(() => { 3641 906 : window.setCursorLine(10); 3642 907 : }); 3643 908 : await page.waitForTimeout(100); 3644 909 : 3645 910 : const pos2 = await page.evaluate(() => { 3646 911 : return document.querySelector('.vim-position-info')?.textContent || ''; 3647 912 : }); 3648 913 : expect(pos2).toContain('Ln 10'); 3649 914 : }); 3650 915 : 3651 916 : test('status line shows NORMAL mode initially', async () => { 3652 917 : const modeText = await page.evaluate(() => { 3653 918 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 3654 919 : return modeIndicator?.textContent || ''; 3655 920 : }); 3656 921 : 3657 922 : expect(modeText).toBe('NORMAL'); 3658 923 : }); 3659 924 : 3660 925 : test('status line shows INSERT mode when entering insert mode', async () => { 3661 926 : await page.evaluate(() => { 3662 927 : window.setCursorLine(1); 3663 928 : window.editorView.focus(); 3664 929 : }); 3665 930 : await page.waitForTimeout(50); 3666 931 : 3667 932 : // Press 'i' to enter insert mode 3668 933 : await page.keyboard.press('i'); 3669 934 : await page.waitForTimeout(100); 3670 935 : 3671 936 : const modeText = await page.evaluate(() => { 3672 937 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 3673 938 : return modeIndicator?.textContent || ''; 3674 939 : }); 3675 940 : 3676 941 : expect(modeText).toContain('INSERT'); 3677 942 : 3678 943 : // Press Escape to exit insert mode 3679 944 : await page.keyboard.press('Escape'); 3680 945 : await page.waitForTimeout(100); 3681 946 : 3682 947 : const normalText = await page.evaluate(() => { 3683 948 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 3684 949 : return modeIndicator?.textContent || ''; 3685 950 : }); 3686 951 : 3687 952 : expect(normalText).toBe('NORMAL'); 3688 953 : }); 3689 954 : 3690 955 : test('status line shows VISUAL mode when entering visual mode', async () => { 3691 956 : await page.evaluate(() => { 3692 957 : window.setCursorLine(1); 3693 958 : window.editorView.focus(); 3694 959 : }); 3695 960 : await page.waitForTimeout(50); 3696 961 : 3697 962 : // Press 'v' to enter visual mode 3698 963 : await page.keyboard.press('v'); 3699 964 : await page.waitForTimeout(100); 3700 965 : 3701 966 : const modeText = await page.evaluate(() => { 3702 967 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 3703 968 : return modeIndicator?.textContent || ''; 3704 969 : }); 3705 970 : 3706 971 : expect(modeText).toContain('VISUAL'); 3707 972 : 3708 973 : // Press Escape to exit visual mode 3709 974 : await page.keyboard.press('Escape'); 3710 975 : }); 3711 976 : }); 3712 977 : }); 3713 978 : 3714 979 : // TypeScript declarations 3715 980 : declare global { 3716 981 : interface Window { 3717 982 : editorView: any; 3718 983 : foldAll: () => boolean; 3719 984 : unfoldAll: () => boolean; 3720 985 : foldCode: (pos: number) => boolean; 3721 986 : unfoldCode: (pos: number) => boolean; 3722 987 : foldable: (pos: number) => { from: number; to: number } | null; 3723 988 : setVimMode: (enabled: boolean) => void; 3724 989 : getCursorLine: () => number; 3725 990 : setCursorLine: (lineNum: number) => void; 3726 991 : } 3727 992 : } 3728Removed regular file tests/editor/test-page.html: 3729 1 : <!DOCTYPE html> 3730 2 : <html lang="en"> 3731 3 : <head> 3732 4 : <meta charset="UTF-8"> 3733 5 : <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3734 6 : <title>Editor Folding Test Page</title> 3735 7 : <script type="importmap"> 3736 8 : { 3737 9 : "imports": { 3738 10 : "@codemirror/state": "/node_modules/@codemirror/state/dist/index.js", 3739 11 : "@codemirror/view": "/node_modules/@codemirror/view/dist/index.js", 3740 12 : "@codemirror/commands": "/node_modules/@codemirror/commands/dist/index.js", 3741 13 : "@codemirror/language": "/node_modules/@codemirror/language/dist/index.js", 3742 14 : "@codemirror/autocomplete": "/node_modules/@codemirror/autocomplete/dist/index.js", 3743 15 : "@codemirror/lang-markdown": "/node_modules/@codemirror/lang-markdown/dist/index.js", 3744 16 : "@codemirror/lang-html": "/node_modules/@codemirror/lang-html/dist/index.js", 3745 17 : "@codemirror/lang-css": "/node_modules/@codemirror/lang-css/dist/index.js", 3746 18 : "@codemirror/lang-javascript": "/node_modules/@codemirror/lang-javascript/dist/index.js", 3747 19 : "@codemirror/theme-one-dark": "/node_modules/@codemirror/theme-one-dark/dist/index.js", 3748 20 : "@codemirror/search": "/node_modules/@codemirror/search/dist/index.js", 3749 21 : "@replit/codemirror-vim": "/node_modules/@replit/codemirror-vim/dist/index.js", 3750 22 : "@lezer/common": "/node_modules/@lezer/common/dist/index.js", 3751 23 : "@lezer/highlight": "/node_modules/@lezer/highlight/dist/index.js", 3752 24 : "@lezer/lr": "/node_modules/@lezer/lr/dist/index.js", 3753 25 : "@lezer/markdown": "/node_modules/@lezer/markdown/dist/index.js", 3754 26 : "@lezer/html": "/node_modules/@lezer/html/dist/index.js", 3755 27 : "@lezer/css": "/node_modules/@lezer/css/dist/index.js", 3756 28 : "@lezer/javascript": "/node_modules/@lezer/javascript/dist/index.js", 3757 29 : "crelt": "/node_modules/crelt/index.js", 3758 30 : "style-mod": "/node_modules/style-mod/src/style-mod.js", 3759 31 : "w3c-keyname": "/node_modules/w3c-keyname/index.js", 3760 32 : "@marijn/find-cluster-break": "/node_modules/@marijn/find-cluster-break/src/index.js" 3761 33 : } 3762 34 : } 3763 35 : </script> 3764 36 : <style> 3765 37 : :root { 3766 38 : /* Base16 Tomorrow Night theme */ 3767 39 : --base00: #1d1f21; 3768 40 : --base01: #282a2e; 3769 41 : --base02: #373b41; 3770 42 : --base03: #969896; 3771 43 : --base04: #b4b7b4; 3772 44 : --base05: #c5c8c6; 3773 45 : --base06: #e0e0e0; 3774 46 : --base07: #ffffff; 3775 47 : --base08: #cc6666; 3776 48 : --base09: #de935f; 3777 49 : --base0A: #f0c674; 3778 50 : --base0B: #b5bd68; 3779 51 : --base0C: #8abeb7; 3780 52 : --base0D: #81a2be; 3781 53 : --base0E: #b294bb; 3782 54 : --base0F: #a3685a; 3783 55 : --theme-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3784 56 : } 3785 57 : body { 3786 58 : margin: 0; 3787 59 : padding: 20px; 3788 60 : background: var(--base00); 3789 61 : color: var(--base05); 3790 62 : font-family: var(--theme-font-mono); 3791 63 : } 3792 64 : #editor-container { 3793 65 : width: 100%; 3794 66 : height: 600px; 3795 67 : border: 1px solid var(--base02); 3796 68 : border-radius: 8px; 3797 69 : overflow: hidden; 3798 70 : } 3799 71 : .cm-editor { 3800 72 : height: 100%; 3801 73 : } 3802 74 : #controls { 3803 75 : margin-bottom: 16px; 3804 76 : display: flex; 3805 77 : gap: 12px; 3806 78 : align-items: center; 3807 79 : } 3808 80 : button { 3809 81 : background: var(--base02); 3810 82 : color: var(--base05); 3811 83 : border: 1px solid var(--base03); 3812 84 : padding: 8px 16px; 3813 85 : border-radius: 4px; 3814 86 : cursor: pointer; 3815 87 : font-family: inherit; 3816 88 : } 3817 89 : button:hover { 3818 90 : background: var(--base03); 3819 91 : } 3820 92 : label { 3821 93 : display: flex; 3822 94 : align-items: center; 3823 95 : gap: 8px; 3824 96 : } 3825 97 : #status { 3826 98 : margin-top: 16px; 3827 99 : padding: 12px; 3828 100 : background: var(--base01); 3829 101 : border-radius: 4px; 3830 102 : font-size: 12px; 3831 103 : } 3832 104 : </style> 3833 105 : </head> 3834 106 : <body> 3835 107 : <div id="controls"> 3836 108 : <label> 3837 109 : <input type="checkbox" id="vim-toggle"> 3838 110 : Vim Mode 3839 111 : </label> 3840 112 : <button id="fold-all">Fold All (zM)</button> 3841 113 : <button id="unfold-all">Unfold All (zR)</button> 3842 114 : </div> 3843 115 : 3844 116 : <div id="editor-container"></div> 3845 117 : 3846 118 : <!-- Vim-style status line --> 3847 119 : <div id="vim-status-line" style="display: none; justify-content: space-between; align-items: center; padding: 4px 12px; background: var(--base00); border-top: 1px solid var(--base02); min-height: 22px; font-family: var(--theme-font-mono); font-size: 12px;"> 3848 120 : <span class="vim-mode-indicator" style="color: var(--base0A); font-weight: 600;">NORMAL</span> 3849 121 : <span class="vim-position-info" style="color: var(--base04);">Ln 1, Col 1</span> 3850 122 : </div> 3851 123 : 3852 124 : <div id="status"> 3853 125 : <div id="fold-count">Folds: 0</div> 3854 126 : <div id="cursor-pos">Cursor: 1:1</div> 3855 127 : </div> 3856 128 : 3857 129 : <script type="module"> 3858 130 : import { EditorState, Compartment } from '@codemirror/state'; 3859 131 : import { 3860 132 : EditorView, 3861 133 : keymap, 3862 134 : lineNumbers, 3863 135 : highlightActiveLine, 3864 136 : highlightActiveLineGutter, 3865 137 : drawSelection 3866 138 : } from '@codemirror/view'; 3867 139 : import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; 3868 140 : import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; 3869 141 : import { 3870 142 : syntaxHighlighting, 3871 143 : defaultHighlightStyle, 3872 144 : bracketMatching, 3873 145 : indentOnInput, 3874 146 : foldGutter, 3875 147 : foldService, 3876 148 : codeFolding, 3877 149 : foldAll, 3878 150 : unfoldAll, 3879 151 : foldCode, 3880 152 : unfoldCode, 3881 153 : foldable, 3882 154 : foldedRanges, 3883 155 : foldEffect, 3884 156 : unfoldEffect 3885 157 : } from '@codemirror/language'; 3886 158 : import { vim, Vim, getCM } from '@replit/codemirror-vim'; 3887 159 : import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; 3888 160 : 3889 161 : // Helper: Find folded range at a position 3890 162 : function findFoldedRangeAt(state, pos) { 3891 163 : const folded = foldedRanges(state); 3892 164 : let result = null; 3893 165 : folded.between(0, state.doc.length, (from, to) => { 3894 166 : if (pos >= from && pos <= to) { 3895 167 : result = { from, to }; 3896 168 : } 3897 169 : }); 3898 170 : return result; 3899 171 : } 3900 172 : 3901 173 : // Define vim fold commands using folditall-style region finding 3902 174 : Vim.defineAction('foldAll', (cm) => { 3903 175 : foldAll(cm.cm6); 3904 176 : }); 3905 177 : 3906 178 : Vim.defineAction('unfoldAll', (cm) => { 3907 179 : unfoldAll(cm.cm6); 3908 180 : }); 3909 181 : 3910 182 : Vim.defineAction('foldCode', (cm) => { 3911 183 : const view = cm.cm6; 3912 184 : const pos = view.state.selection.main.head; 3913 185 : const lineNum = view.state.doc.lineAt(pos).number; 3914 186 : 3915 187 : const foldStart = findContainingFoldStart(view.state, lineNum); 3916 188 : if (foldStart !== null) { 3917 189 : const foldRange = foldable(view.state, foldStart, foldStart); 3918 190 : if (foldRange) { 3919 191 : view.dispatch({ 3920 192 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to }) 3921 193 : }); 3922 194 : } 3923 195 : } 3924 196 : }); 3925 197 : 3926 198 : Vim.defineAction('unfoldCode', (cm) => { 3927 199 : const view = cm.cm6; 3928 200 : const pos = view.state.selection.main.head; 3929 201 : const line = view.state.doc.lineAt(pos); 3930 202 : const lineNum = line.number; 3931 203 : 3932 204 : // First check if we're in a folded range 3933 205 : const foldedRange = findFoldedRangeAt(view.state, pos); 3934 206 : if (foldedRange) { 3935 207 : view.dispatch({ 3936 208 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to }) 3937 209 : }); 3938 210 : return; 3939 211 : } 3940 212 : 3941 213 : // Check if there's a fold starting at the end of current line 3942 214 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to); 3943 215 : if (foldAtLineEnd) { 3944 216 : view.dispatch({ 3945 217 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to }) 3946 218 : }); 3947 219 : return; 3948 220 : } 3949 221 : 3950 222 : const foldStart = findContainingFoldStart(view.state, lineNum); 3951 223 : if (foldStart !== null) { 3952 224 : const foldStartLine = view.state.doc.lineAt(foldStart); 3953 225 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to); 3954 226 : if (foldRange) { 3955 227 : const folded = foldedRanges(view.state); 3956 228 : let isFolded = false; 3957 229 : folded.between(foldRange.from, foldRange.to, (from) => { 3958 230 : if (from === foldRange.from) isFolded = true; 3959 231 : }); 3960 232 : if (isFolded) { 3961 233 : view.dispatch({ 3962 234 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 3963 235 : }); 3964 236 : } 3965 237 : } 3966 238 : } 3967 239 : }); 3968 240 : 3969 241 : Vim.defineAction('toggleFold', (cm) => { 3970 242 : const view = cm.cm6; 3971 243 : const pos = view.state.selection.main.head; 3972 244 : const lineNum = view.state.doc.lineAt(pos).number; 3973 245 : 3974 246 : const foldStart = findContainingFoldStart(view.state, lineNum); 3975 247 : if (foldStart === null) return; 3976 248 : 3977 249 : const line = view.state.doc.lineAt(foldStart); 3978 250 : const foldRange = foldable(view.state, line.from, line.to); 3979 251 : if (!foldRange) return; 3980 252 : 3981 253 : const folded = foldedRanges(view.state); 3982 254 : let isFolded = false; 3983 255 : folded.between(foldRange.from, foldRange.to, (from) => { 3984 256 : if (from === foldRange.from) isFolded = true; 3985 257 : }); 3986 258 : 3987 259 : if (isFolded) { 3988 260 : view.dispatch({ 3989 261 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 3990 262 : }); 3991 263 : } else { 3992 264 : view.dispatch({ 3993 265 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to }) 3994 266 : }); 3995 267 : } 3996 268 : }); 3997 269 : 3998 270 : // Map vim fold commands 3999 271 : Vim.mapCommand('zc', 'action', 'foldCode', {}, { context: 'normal' }); 4000 272 : Vim.mapCommand('zo', 'action', 'unfoldCode', {}, { context: 'normal' }); 4001 273 : Vim.mapCommand('za', 'action', 'toggleFold', {}, { context: 'normal' }); 4002 274 : Vim.mapCommand('zM', 'action', 'foldAll', {}, { context: 'normal' }); 4003 275 : Vim.mapCommand('zR', 'action', 'unfoldAll', {}, { context: 'normal' }); 4004 276 : Vim.mapCommand('zr', 'action', 'unfoldAll', {}, { context: 'normal' }); 4005 277 : Vim.mapCommand('zm', 'action', 'foldAll', {}, { context: 'normal' }); 4006 278 : // Space toggles fold (like za) - folditall behavior 4007 279 : Vim.mapCommand('<Space>', 'action', 'toggleFold', {}, { context: 'normal' }); 4008 280 : 4009 281 : // Test content with all folding types 4010 282 : const TEST_CONTENT = `# Level 1 Header - Main Document 4011 283 : 4012 284 : This content is under a level 1 header. 4013 285 : 4014 286 : ## Level 2 - Features 4015 287 : 4016 288 : Content under level 2. 4017 289 : 4018 290 : ### Level 3 - Folding Types 4019 291 : 4020 292 : We support multiple folding types. 4021 293 : 4022 294 : #### Level 4 - Header Folding 4023 295 : 4024 296 : Headers fold everything until the next header of same or higher level. 4025 297 : 4026 298 : ##### Level 5 - Deep Nesting 4027 299 : 4028 300 : This is deeply nested content. 4029 301 : 4030 302 : ###### Level 6 - Maximum Depth 4031 303 : 4032 304 : This is the deepest header level supported. 4033 305 : 4034 306 : Back to level 5 content. 4035 307 : 4036 308 : ##### Level 5 - Another Section 4037 309 : 4038 310 : Another level 5 section. 4039 311 : 4040 312 : #### Level 4 - List Folding 4041 313 : 4042 314 : Lists with children are foldable: 4043 315 : 4044 316 : - Parent item with children 4045 317 : - Child item one 4046 318 : - Child item two 4047 319 : - Grandchild item 4048 320 : - Another grandchild 4049 321 : - Child item three 4050 322 : - Simple item (no children) 4051 323 : - Another parent 4052 324 : - Single child 4053 325 : 4054 326 : Numbered lists also fold: 4055 327 : 4056 328 : 1. First parent 4057 329 : 1. Sub-item one 4058 330 : 2. Sub-item two 4059 331 : 2. Second parent 4060 332 : - Mixed child 4061 333 : - Another mixed 4062 334 : 4063 335 : #### Level 4 - Code Block Folding 4064 336 : 4065 337 : Top-level code blocks fold their contents: 4066 338 : 4067 339 : \`\`\`javascript 4068 340 : function example() { 4069 341 : if (condition) { 4070 342 : doSomething(); 4071 343 : } 4072 344 : return result; 4073 345 : } 4074 346 : 4075 347 : class MyClass { 4076 348 : constructor() { 4077 349 : this.x = 1; 4078 350 : } 4079 351 : 4080 352 : method() { 4081 353 : return this.x; 4082 354 : } 4083 355 : } 4084 356 : \`\`\` 4085 357 : 4086 358 : ### Level 3 - Vim Fold Commands 4087 359 : 4088 360 : Test these vim commands (enable vim mode first): 4089 361 : 4090 362 : | Command | Action | 4091 363 : |---------|--------| 4092 364 : | \`za\` | Toggle fold under cursor | 4093 365 : | \`zo\` | Open fold under cursor | 4094 366 : | \`zc\` | Close fold under cursor | 4095 367 : | \`zR\` | Open all folds | 4096 368 : | \`zM\` | Close all folds | 4097 369 : | \`zr\` | Reduce folding (open one level) | 4098 370 : | \`zm\` | More folding (close one level) | 4099 371 : 4100 372 : ## Level 2 - Another Top Section 4101 373 : 4102 374 : This tests that level 2 properly ends the previous level 2 section. 4103 375 : 4104 376 : ### Level 3 - Final Nested 4105 377 : 4106 378 : Final nested content. 4107 379 : 4108 380 : ## Level 2 - Conclusion 4109 381 : 4110 382 : End of test document. 4111 383 : `; 4112 384 : 4113 385 : // Compartments for runtime-reconfigurable extensions 4114 386 : const vimCompartment = new Compartment(); 4115 387 : 4116 388 : // ======================================================================== 4117 389 : // Folditall Algorithm Helpers 4118 390 : // ======================================================================== 4119 391 : 4120 392 : function getHeaderLevel(text) { 4121 393 : const match = text.match(/^(#{1,6})\s+/); 4122 394 : return match ? match[1].length : 0; 4123 395 : } 4124 396 : 4125 397 : function isListItem(text) { 4126 398 : return /^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text); 4127 399 : } 4128 400 : 4129 401 : function getIndent(text) { 4130 402 : let indent = 0; 4131 403 : for (const char of text) { 4132 404 : if (char === ' ') indent++; 4133 405 : else if (char === '\t') indent += 2; 4134 406 : else break; 4135 407 : } 4136 408 : return indent; 4137 409 : } 4138 410 : 4139 411 : function isBlankLine(text) { 4140 412 : return /^\s*$/.test(text); 4141 413 : } 4142 414 : 4143 415 : function isFencedCodeBlockOpener(text) { 4144 416 : return /^(`{3,}|~{3,})/.test(text.trim()); 4145 417 : } 4146 418 : 4147 419 : function findClosingFence(doc, openerLineNum) { 4148 420 : const openerLine = doc.line(openerLineNum); 4149 421 : const openerText = openerLine.text.trim(); 4150 422 : const match = openerText.match(/^(`{3,}|~{3,})/); 4151 423 : if (!match) return null; 4152 424 : 4153 425 : const fenceChar = match[1][0]; 4154 426 : const fenceLen = match[1].length; 4155 427 : const totalLines = doc.lines; 4156 428 : 4157 429 : for (let i = openerLineNum + 1; i <= totalLines; i++) { 4158 430 : const line = doc.line(i); 4159 431 : const trimmed = line.text.trim(); 4160 432 : const closeMatch = trimmed.match(new RegExp(`^${fenceChar}{${fenceLen},}$`)); 4161 433 : if (closeMatch) { 4162 434 : return i; 4163 435 : } 4164 436 : } 4165 437 : return null; 4166 438 : } 4167 439 : 4168 440 : function findNextNonBlank(doc, lineNum) { 4169 441 : const totalLines = doc.lines; 4170 442 : for (let i = lineNum + 1; i <= totalLines; i++) { 4171 443 : const line = doc.line(i); 4172 444 : if (!isBlankLine(line.text)) { 4173 445 : return i; 4174 446 : } 4175 447 : } 4176 448 : return null; 4177 449 : } 4178 450 : 4179 451 : function canStartFold(text) { 4180 452 : if (getHeaderLevel(text) > 0) return true; 4181 453 : if (isListItem(text)) return true; 4182 454 : if (isFencedCodeBlockOpener(text)) return true; 4183 455 : if (getIndent(text) === 0 && !isBlankLine(text)) return true; 4184 456 : return false; 4185 457 : } 4186 458 : 4187 459 : function hasFoldableChildren(doc, lineNum) { 4188 460 : const line = doc.line(lineNum); 4189 461 : const text = line.text; 4190 462 : 4191 463 : if (getHeaderLevel(text) > 0) return true; 4192 464 : 4193 465 : if (isFencedCodeBlockOpener(text)) { 4194 466 : return findClosingFence(doc, lineNum) !== null; 4195 467 : } 4196 468 : 4197 469 : const currentIndent = getIndent(text); 4198 470 : const nextNonBlankNum = findNextNonBlank(doc, lineNum); 4199 471 : 4200 472 : if (nextNonBlankNum === null) return false; 4201 473 : 4202 474 : const nextLine = doc.line(nextNonBlankNum); 4203 475 : const nextText = nextLine.text; 4204 476 : 4205 477 : if (getHeaderLevel(nextText) > 0) return false; 4206 478 : 4207 479 : return getIndent(nextText) > currentIndent; 4208 480 : } 4209 481 : 4210 482 : function findFoldEnd(doc, lineNum) { 4211 483 : const line = doc.line(lineNum); 4212 484 : const text = line.text; 4213 485 : const totalLines = doc.lines; 4214 486 : const headerLevel = getHeaderLevel(text); 4215 487 : 4216 488 : if (headerLevel > 0) { 4217 489 : for (let i = lineNum + 1; i <= totalLines; i++) { 4218 490 : const checkLine = doc.line(i); 4219 491 : const checkLevel = getHeaderLevel(checkLine.text); 4220 492 : if (checkLevel > 0 && checkLevel <= headerLevel) { 4221 493 : return i - 1; 4222 494 : } 4223 495 : } 4224 496 : return totalLines; 4225 497 : } 4226 498 : 4227 499 : if (isFencedCodeBlockOpener(text)) { 4228 500 : const closingLine = findClosingFence(doc, lineNum); 4229 501 : return closingLine !== null ? closingLine : lineNum; 4230 502 : } 4231 503 : 4232 504 : const startIndent = getIndent(text); 4233 505 : let lastContentLine = lineNum; 4234 506 : 4235 507 : for (let i = lineNum + 1; i <= totalLines; i++) { 4236 508 : const checkLine = doc.line(i); 4237 509 : const checkText = checkLine.text; 4238 510 : 4239 511 : if (isBlankLine(checkText)) continue; 4240 512 : 4241 513 : if (getHeaderLevel(checkText) > 0) { 4242 514 : return lastContentLine; 4243 515 : } 4244 516 : 4245 517 : const checkIndent = getIndent(checkText); 4246 518 : 4247 519 : if (checkIndent <= startIndent) { 4248 520 : return lastContentLine; 4249 521 : } 4250 522 : 4251 523 : lastContentLine = i; 4252 524 : } 4253 525 : 4254 526 : return lastContentLine; 4255 527 : } 4256 528 : 4257 529 : function findContainingFoldStart(state, lineNum) { 4258 530 : const doc = state.doc; 4259 531 : const currentLine = doc.line(lineNum); 4260 532 : const currentText = currentLine.text; 4261 533 : 4262 534 : if (canStartFold(currentText) && hasFoldableChildren(doc, lineNum)) { 4263 535 : return currentLine.from; 4264 536 : } 4265 537 : 4266 538 : const currentIndent = getIndent(currentText); 4267 539 : 4268 540 : for (let i = lineNum - 1; i >= 1; i--) { 4269 541 : const line = doc.line(i); 4270 542 : const text = line.text; 4271 543 : 4272 544 : if (isBlankLine(text)) continue; 4273 545 : 4274 546 : const lineIndent = getIndent(text); 4275 547 : const headerLevel = getHeaderLevel(text); 4276 548 : 4277 549 : if (headerLevel > 0) { 4278 550 : const foldEnd = findFoldEnd(doc, i); 4279 551 : if (foldEnd >= lineNum) { 4280 552 : return line.from; 4281 553 : } 4282 554 : continue; 4283 555 : } 4284 556 : 4285 557 : if (lineIndent < currentIndent && canStartFold(text) && hasFoldableChildren(doc, i)) { 4286 558 : const foldEnd = findFoldEnd(doc, i); 4287 559 : if (foldEnd >= lineNum) { 4288 560 : return line.from; 4289 561 : } 4290 562 : } 4291 563 : } 4292 564 : 4293 565 : return null; 4294 566 : } 4295 567 : 4296 568 : // Folditall-style fold service 4297 569 : const folditallFoldService = foldService.of((state, lineStart, lineEnd) => { 4298 570 : const doc = state.doc; 4299 571 : const line = doc.lineAt(lineStart); 4300 572 : const text = line.text; 4301 573 : const lineNum = line.number; 4302 574 : 4303 575 : if (isBlankLine(text)) return null; 4304 576 : if (!canStartFold(text)) return null; 4305 577 : if (!hasFoldableChildren(doc, lineNum)) return null; 4306 578 : 4307 579 : const endLineNum = findFoldEnd(doc, lineNum); 4308 580 : if (endLineNum <= lineNum) return null; 4309 581 : 4310 582 : const endLine = doc.line(endLineNum); 4311 583 : return { from: line.to, to: endLine.to }; 4312 584 : }); 4313 585 : 4314 586 : // Create editor theme 4315 587 : const editorTheme = EditorView.theme({ 4316 588 : '&': { 4317 589 : fontSize: '14px', 4318 590 : fontFamily: 'var(--theme-font-mono)', 4319 591 : backgroundColor: 'var(--base01)', 4320 592 : color: 'var(--base05)', 4321 593 : }, 4322 594 : '&.cm-focused': { 4323 595 : outline: 'none', 4324 596 : }, 4325 597 : '.cm-content': { 4326 598 : padding: '10px 14px', 4327 599 : caretColor: 'var(--base05)', 4328 600 : }, 4329 601 : '.cm-cursor, .cm-dropCursor': { 4330 602 : borderLeftColor: 'var(--base05)', 4331 603 : }, 4332 604 : '.cm-selectionBackground, ::selection': { 4333 605 : backgroundColor: 'var(--base02)', 4334 606 : }, 4335 607 : '.cm-activeLine': { 4336 608 : backgroundColor: 'var(--base02)', 4337 609 : }, 4338 610 : '.cm-activeLineGutter': { 4339 611 : backgroundColor: 'var(--base02)', 4340 612 : }, 4341 613 : '.cm-gutters': { 4342 614 : backgroundColor: 'var(--base01)', 4343 615 : color: 'var(--base03)', 4344 616 : border: 'none', 4345 617 : borderRight: '1px solid var(--base02)', 4346 618 : }, 4347 619 : '.cm-lineNumbers .cm-gutterElement': { 4348 620 : padding: '0 8px 0 4px', 4349 621 : }, 4350 622 : '.cm-foldGutter': { 4351 623 : width: '12px', 4352 624 : }, 4353 625 : '.cm-header': { 4354 626 : color: 'var(--base0D)', 4355 627 : fontWeight: '600', 4356 628 : }, 4357 629 : '.cm-fat-cursor': { 4358 630 : backgroundColor: 'var(--base05) !important', 4359 631 : color: 'var(--base00) !important', 4360 632 : }, 4361 633 : '&:not(.cm-focused) .cm-fat-cursor': { 4362 634 : backgroundColor: 'transparent !important', 4363 635 : outline: '1px solid var(--base05)', 4364 636 : }, 4365 637 : '.cm-vim-panel': { 4366 638 : padding: '4px 10px', 4367 639 : backgroundColor: 'var(--base00)', 4368 640 : borderTop: '1px solid var(--base02)', 4369 641 : fontFamily: 'var(--theme-font-mono)', 4370 642 : fontSize: '13px', 4371 643 : color: 'var(--base04)', 4372 644 : }, 4373 645 : '.cm-vim-panel input': { 4374 646 : backgroundColor: 'transparent', 4375 647 : border: 'none', 4376 648 : outline: 'none', 4377 649 : color: 'var(--base05)', 4378 650 : fontFamily: 'inherit', 4379 651 : fontSize: 'inherit', 4380 652 : }, 4381 653 : }, { dark: true }); 4382 654 : 4383 655 : // Build extensions 4384 656 : const extensions = [ 4385 657 : history(), 4386 658 : drawSelection(), 4387 659 : indentOnInput(), 4388 660 : bracketMatching(), 4389 661 : highlightActiveLine(), 4390 662 : highlightActiveLineGutter(), 4391 663 : highlightSelectionMatches(), 4392 664 : 4393 665 : keymap.of([ 4394 666 : ...defaultKeymap, 4395 667 : ...historyKeymap, 4396 668 : ...searchKeymap, 4397 669 : indentWithTab, 4398 670 : ]), 4399 671 : 4400 672 : markdown({ base: markdownLanguage }), 4401 673 : syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 4402 674 : 4403 675 : // Folding support 4404 676 : codeFolding(), 4405 677 : folditallFoldService, 4406 678 : lineNumbers(), 4407 679 : foldGutter(), 4408 680 : 4409 681 : // Theme 4410 682 : editorTheme, 4411 683 : 4412 684 : // Vim mode (starts disabled) 4413 685 : vimCompartment.of([]), 4414 686 : 4415 687 : // Update listener for status 4416 688 : EditorView.updateListener.of(update => { 4417 689 : if (update.selectionSet) { 4418 690 : const pos = update.state.selection.main.head; 4419 691 : const line = update.state.doc.lineAt(pos); 4420 692 : const col = pos - line.from + 1; 4421 693 : document.getElementById('cursor-pos').textContent = 4422 694 : `Cursor: ${line.number}:${col}`; 4423 695 : 4424 696 : // Update vim status line position 4425 697 : const posInfo = document.querySelector('.vim-position-info'); 4426 698 : if (posInfo) { 4427 699 : posInfo.textContent = `Ln ${line.number}, Col ${col}`; 4428 700 : } 4429 701 : } 4430 702 : 4431 703 : // Check for vim mode changes 4432 704 : const statusLine = document.getElementById('vim-status-line'); 4433 705 : if (statusLine && statusLine.style.display !== 'none') { 4434 706 : try { 4435 707 : const cm = getCM(update.view); 4436 708 : if (cm && cm.state && cm.state.vim) { 4437 709 : const vimState = cm.state.vim; 4438 710 : let currentMode = 'normal'; 4439 711 : let modeLabel = 'NORMAL'; 4440 712 : let modeColor = 'var(--base0A)'; 4441 713 : 4442 714 : if (vimState.insertMode) { 4443 715 : currentMode = 'insert'; 4444 716 : modeLabel = '-- INSERT --'; 4445 717 : modeColor = 'var(--base0B)'; 4446 718 : } else if (vimState.visualMode) { 4447 719 : if (vimState.visualLine) { 4448 720 : currentMode = 'visual-line'; 4449 721 : modeLabel = '-- V-LINE --'; 4450 722 : } else if (vimState.visualBlock) { 4451 723 : currentMode = 'visual-block'; 4452 724 : modeLabel = '-- V-BLOCK --'; 4453 725 : } else { 4454 726 : currentMode = 'visual'; 4455 727 : modeLabel = '-- VISUAL --'; 4456 728 : } 4457 729 : modeColor = 'var(--base0E)'; 4458 730 : } 4459 731 : 4460 732 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 4461 733 : if (modeIndicator) { 4462 734 : modeIndicator.textContent = modeLabel; 4463 735 : modeIndicator.style.color = modeColor; 4464 736 : } 4465 737 : } 4466 738 : } catch (e) { 4467 739 : // Vim not active 4468 740 : } 4469 741 : } 4470 742 : }), 4471 743 : ]; 4472 744 : 4473 745 : // Create editor 4474 746 : const state = EditorState.create({ 4475 747 : doc: TEST_CONTENT, 4476 748 : extensions, 4477 749 : }); 4478 750 : 4479 751 : const view = new EditorView({ 4480 752 : state, 4481 753 : parent: document.getElementById('editor-container'), 4482 754 : }); 4483 755 : 4484 756 : // Expose for testing 4485 757 : window.editorView = view; 4486 758 : window.editorState = state; 4487 759 : window.foldAll = () => foldAll(view); 4488 760 : window.unfoldAll = () => unfoldAll(view); 4489 761 : 4490 762 : // Folditall-aware foldCode: folds the containing fold region 4491 763 : window.foldCode = (pos) => { 4492 764 : const lineNum = view.state.doc.lineAt(pos).number; 4493 765 : const foldStart = findContainingFoldStart(view.state, lineNum); 4494 766 : if (foldStart !== null) { 4495 767 : const foldRange = foldable(view.state, foldStart, foldStart); 4496 768 : if (foldRange) { 4497 769 : view.dispatch({ 4498 770 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to }) 4499 771 : }); 4500 772 : return true; 4501 773 : } 4502 774 : } 4503 775 : return false; 4504 776 : }; 4505 777 : 4506 778 : // Folditall-aware unfoldCode 4507 779 : window.unfoldCode = (pos) => { 4508 780 : const line = view.state.doc.lineAt(pos); 4509 781 : const lineNum = line.number; 4510 782 : 4511 783 : // Check if cursor is in a folded range 4512 784 : const foldedRange = findFoldedRangeAt(view.state, pos); 4513 785 : if (foldedRange) { 4514 786 : view.dispatch({ 4515 787 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to }) 4516 788 : }); 4517 789 : return true; 4518 790 : } 4519 791 : 4520 792 : // Check if there's a fold starting at the end of current line 4521 793 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to); 4522 794 : if (foldAtLineEnd) { 4523 795 : view.dispatch({ 4524 796 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to }) 4525 797 : }); 4526 798 : return true; 4527 799 : } 4528 800 : 4529 801 : const foldStart = findContainingFoldStart(view.state, lineNum); 4530 802 : if (foldStart !== null) { 4531 803 : const foldStartLine = view.state.doc.lineAt(foldStart); 4532 804 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to); 4533 805 : if (foldRange) { 4534 806 : const folded = foldedRanges(view.state); 4535 807 : let isFolded = false; 4536 808 : folded.between(foldRange.from, foldRange.to, (from) => { 4537 809 : if (from === foldRange.from) isFolded = true; 4538 810 : }); 4539 811 : if (isFolded) { 4540 812 : view.dispatch({ 4541 813 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 4542 814 : }); 4543 815 : return true; 4544 816 : } 4545 817 : } 4546 818 : } 4547 819 : return false; 4548 820 : }; 4549 821 : 4550 822 : window.foldable = (pos) => foldable(view.state, pos, pos); 4551 823 : 4552 824 : window.setVimMode = (enabled) => { 4553 825 : view.dispatch({ 4554 826 : effects: vimCompartment.reconfigure(enabled ? vim() : []), 4555 827 : }); 4556 828 : 4557 829 : // Show/hide vim status line 4558 830 : const statusLine = document.getElementById('vim-status-line'); 4559 831 : if (statusLine) { 4560 832 : statusLine.style.display = enabled ? 'flex' : 'none'; 4561 833 : if (enabled) { 4562 834 : const modeIndicator = document.querySelector('.vim-mode-indicator'); 4563 835 : if (modeIndicator) { 4564 836 : modeIndicator.textContent = 'NORMAL'; 4565 837 : modeIndicator.style.color = 'var(--base0A)'; 4566 838 : } 4567 839 : } 4568 840 : } 4569 841 : }; 4570 842 : 4571 843 : window.getCursorLine = () => { 4572 844 : const pos = view.state.selection.main.head; 4573 845 : return view.state.doc.lineAt(pos).number; 4574 846 : }; 4575 847 : 4576 848 : window.setCursorLine = (lineNum) => { 4577 849 : const line = view.state.doc.line(lineNum); 4578 850 : view.dispatch({ 4579 851 : selection: { anchor: line.from }, 4580 852 : scrollIntoView: true, 4581 853 : }); 4582 854 : view.focus(); 4583 855 : }; 4584 856 : 4585 857 : window.getFoldedRanges = () => { 4586 858 : // Get all folded ranges from the fold state 4587 859 : const foldState = view.state.field(codeFolding().spec.provides, false); 4588 860 : if (!foldState) return []; 4589 861 : // Note: This is a simplified check - full implementation would iterate decorations 4590 862 : return []; 4591 863 : }; 4592 864 : 4593 865 : window.isFolded = (lineNum) => { 4594 866 : const line = view.state.doc.line(lineNum); 4595 867 : // Check if the line is at a fold point and is currently folded 4596 868 : const foldRange = foldable(view.state, line.from, line.to); 4597 869 : if (!foldRange) return false; 4598 870 : 4599 871 : // Check the fold decorations 4600 872 : // A line is considered folded if the next visible line is not lineNum + 1 4601 873 : // This is a heuristic - better would be to check fold state directly 4602 874 : return false; // Placeholder - actual implementation needs fold state access 4603 875 : }; 4604 876 : 4605 877 : window.simulateVimCommand = (keys) => { 4606 878 : // Simulate vim keystrokes 4607 879 : view.focus(); 4608 880 : for (const key of keys) { 4609 881 : const event = new KeyboardEvent('keydown', { 4610 882 : key: key, 4611 883 : code: `Key${key.toUpperCase()}`, 4612 884 : bubbles: true, 4613 885 : cancelable: true, 4614 886 : }); 4615 887 : view.contentDOM.dispatchEvent(event); 4616 888 : } 4617 889 : }; 4618 890 : 4619 891 : // UI Controls 4620 892 : document.getElementById('vim-toggle').addEventListener('change', (e) => { 4621 893 : window.setVimMode(e.target.checked); 4622 894 : }); 4623 895 : 4624 896 : document.getElementById('fold-all').addEventListener('click', () => { 4625 897 : window.foldAll(); 4626 898 : }); 4627 899 : 4628 900 : document.getElementById('unfold-all').addEventListener('click', () => { 4629 901 : window.unfoldAll(); 4630 902 : }); 4631 903 : 4632 904 : // Signal ready 4633 905 : document.body.dataset.ready = 'true'; 4634 906 : console.log('[test] Editor ready'); 4635 907 : </script> 4636 908 : </body> 4637 909 : </html> 4638Modified regular file tests/fixtures/desktop-app.ts: 4639 ... 4640 198 198: }); 4641 199 199: 4642 200 200: // Wait for background window to be ready (API loaded) 4643 201 201: // Use 30s timeout to handle slow launches after previous test cleanup 4644 202 201: const bgWindow = await waitForWindowHelper(() => electronApp.windows(), 'app/background.html', 30000); 4645 203 201: await waitForAppReady(bgWindow, 15000); 4646 202: await waitForAppReady(bgWindow, 10000); 4647 204 203: 4648 205 204: // Hybrid mode: wait for extension host (built-in) AND separate windows (external like 'example') 4649 206 205: const waitForHybridExtensions = async (timeout: number): Promise<void> => { 4650 ... 4651 337 336: if (isRunning(pid)) { 4652 338 337: console.error(`[test] WARNING: Process ${pid} still running after SIGKILL`); 4653 339 338: } 4654 340 : 4655 341 : // Allow extra time for OS to fully release resources (ports, files, etc.) 4656 342 : await sleep(500); 4657 343 339: } 4658 344 340: }; 4659 345 341: } 4660 ...