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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ runTerminalCommand('dart analyze');
| Shortcut | Action |
|----------|--------|
| Cmd/Ctrl + P | Quick file search |
| Cmd/Ctrl + Shift + F | Search in files |
| Cmd/Ctrl + O | Open folder |
| Cmd/Ctrl + N | New file |
| Cmd/Ctrl + Click | Go to definition (placeholder) |
Expand Down
82 changes: 82 additions & 0 deletions lib/editor_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import 'widgets/editor/status_bar.dart';
import 'widgets/editor/welcome_screen.dart';
import 'widgets/editor/quick_open_dialog.dart';
import 'widgets/editor/resize_handles.dart';
import 'widgets/editor/global_search_dialog.dart';
import 'services/search_service.dart';
import 'widgets/editor/search_button.dart';

// Global function to run terminal commands from anywhere
void Function(String command)? _globalRunTerminalCommand;
Expand Down Expand Up @@ -199,6 +202,15 @@ class _EditorScreenState extends State<EditorScreen> {
if (event is! KeyDownEvent) return false;

final isMetaPressed = HardwareKeyboard.instance.isMetaPressed;
final isControlPressed = HardwareKeyboard.instance.isControlPressed;
final isShiftPressed = HardwareKeyboard.instance.isShiftPressed;

// Cmd/Ctrl+Shift+F: search across files
if ((isMetaPressed || isControlPressed) && isShiftPressed &&
event.logicalKey == LogicalKeyboardKey.keyF) {
_showGlobalSearch();
return true;
}

if (isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyP) {
_showQuickOpen();
Expand All @@ -213,6 +225,71 @@ class _EditorScreenState extends State<EditorScreen> {
return false; // Event not handled
}

void _showGlobalSearch() {
if (_rootNode == null) return;

final allFiles = _collectAllFiles(_rootNode!);

showDialog(
context: context,
builder: (context) => GlobalSearchDialog(
root: _rootNode!,
files: allFiles,
onMatchSelected: (SearchMatch match) async {
Navigator.of(context).pop();

final file = FileNodeFile(match.fileName, match.filePath);
await _openFile(file);

// Best-effort jump to match in Monaco (waits for model/value to be ready).
await _revealMatchInEditor(match);
},
),
);
}

Future<void> _revealMatchInEditor(SearchMatch match) async {
final controller = _editorController;
if (controller == null) return;

// Ensure Monaco reports ready (webview loaded + editor created).
await controller.onReady;

final line = match.lineNumber;
final startCol = match.matchStartColumn;
final endCol = match.matchStartColumn + match.matchLength;

// Wait until Monaco model is ready (lineCount > 0). This is the real race on macOS.
int lineCount = 0;
for (int i = 0; i < 30; i++) {
await controller.layout();
await controller.ensureEditorFocus(attempts: 1);
lineCount = await controller.getLineCount(defaultValue: 0);
if (lineCount > 0) break;
await Future<void>.delayed(const Duration(milliseconds: 25));
}
if (lineCount <= 0) return;

final safeLine = line.clamp(1, lineCount);

final lineText = await controller.getLineContent(safeLine, defaultValue: '');
final maxCol = (lineText.length + 1).clamp(1, 1 << 30);
final safeStart = startCol.clamp(1, maxCol);
final safeEnd = endCol.clamp(safeStart, maxCol);

// IMPORTANT: Range in flutter_monaco uses startLine/endLine (not startLineNumber).
final range = Range(
startLine: safeLine,
startColumn: safeStart,
endLine: safeLine,
endColumn: safeEnd,
);

await controller.setSelection(range);
await controller.revealRange(range, center: true);
await controller.ensureEditorFocus(attempts: 3);
}

Future<void> _pickDirectory() async {
final root = await fileService.pickDirectory();
if (root != null) {
Expand Down Expand Up @@ -887,6 +964,11 @@ class _EditorScreenState extends State<EditorScreen> {
),

// Actions
if (_rootNode != null)
SearchButton(
onPressed: _showGlobalSearch,
),

if (_rootNode != null)
IconButton(
icon: const Icon(Icons.play_arrow, color: Colors.green, size: 20),
Expand Down
11 changes: 9 additions & 2 deletions lib/services/file_service_io.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'dart:io';
import 'package:flutter/foundation.dart';

import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
Expand Down Expand Up @@ -55,7 +54,15 @@ class FileServiceImpl implements FileService {

@override
Future<String?> readFile(FileNodeFile file) async {
return File(file.path).readAsString();
try {
// Try fast path.
return await File(file.path).readAsString();
} on FileSystemException {
// Likely binary/non-UTF8 file (e.g. png). Skip for text searches.
return null;
} catch (_) {
return null;
}
}

@override
Expand Down
17 changes: 17 additions & 0 deletions lib/services/git_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ class GitService {
);
}

Future<GitServiceResult> getFileDiff(
String workingDirectory,
String path, {
int contextLines = 0,
bool staged = false,
}) {
// \-\-no-color prevents ANSI codes; \-U0 gives only changed lines (no context).
final baseArgs = <String>[
'diff',
'--no-color',
'-U$contextLines',
];
if (staged) baseArgs.insert(1, '--cached');

return _run(workingDirectory, [...baseArgs, '--', path]);
}

Future<bool> isGitRepo(String workingDirectory) async {
final res = await _run(
workingDirectory,
Expand Down
200 changes: 200 additions & 0 deletions lib/services/search_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import 'dart:async';

import 'package:meta/meta.dart';

@immutable
class SearchOptions {
final bool caseSensitive;
final bool regex;
final int maxResults;

const SearchOptions({
this.caseSensitive = false,
this.regex = false,
this.maxResults = 500,
});
}

@immutable
class SearchMatch {
final String filePath;
final String fileName;
final int lineNumber; // 1-based
final int columnNumber; // 1-based
final String lineText;
final int matchStartColumn; // 1-based
final int matchLength;

const SearchMatch({
required this.filePath,
required this.fileName,
required this.lineNumber,
required this.columnNumber,
required this.lineText,
required this.matchStartColumn,
required this.matchLength,
});
}

@immutable
class SearchFileResult {
final String filePath;
final String fileName;
final List<SearchMatch> matches;

const SearchFileResult({
required this.filePath,
required this.fileName,
required this.matches,
});
}

class SearchCancelled implements Exception {
final String message;
SearchCancelled([this.message = 'Search cancelled']);

@override
String toString() => message;
}

class SearchService {
static const List<String> defaultIgnoredPathFragments = <String>[
'/build/',
'/.dart_tool/',
'/.git/',
'/node_modules/',
'/.idea/',
'/.vscode/',
'/ios/Pods/',
'/macos/Pods/',
'/android/app/src/main/res/',
];

bool isIgnoredPath(String path, {List<String> extraIgnoredFragments = const []}) {
for (final frag in [...defaultIgnoredPathFragments, ...extraIgnoredFragments]) {
if (path.contains(frag)) return true;
}
return false;
}

/// Searches [contentByFilePath] for [query].
///
/// This method is pure and testable; call-sites can provide file content
/// loaded through the platform-specific file service.
List<SearchFileResult> searchInMemory({
required Map<String, String> contentByFilePath,
required String query,
SearchOptions options = const SearchOptions(),
}) {
if (query.isEmpty) return const <SearchFileResult>[];

final results = <SearchFileResult>[];
final pattern = options.regex
? RegExp(query, caseSensitive: options.caseSensitive)
: null;
final needle = options.caseSensitive ? query : query.toLowerCase();

int totalMatches = 0;

for (final entry in contentByFilePath.entries) {
if (totalMatches >= options.maxResults) break;

final path = entry.key;
final content = entry.value;

final fileName = _basename(path);

final lines = content.split('\n');
final matches = <SearchMatch>[];

for (int i = 0; i < lines.length; i++) {
if (totalMatches >= options.maxResults) break;

final line = lines[i];

if (options.regex) {
for (final m in pattern!.allMatches(line)) {
if (totalMatches >= options.maxResults) break;

final start0 = m.start;
final length = m.end - m.start;

matches.add(
SearchMatch(
filePath: path,
fileName: fileName,
lineNumber: i + 1,
columnNumber: start0 + 1,
lineText: line,
matchStartColumn: start0 + 1,
matchLength: length,
),
);
totalMatches++;
}
} else {
final haystack = options.caseSensitive ? line : line.toLowerCase();
int from = 0;
while (from <= haystack.length) {
if (totalMatches >= options.maxResults) break;
final idx = haystack.indexOf(needle, from);
if (idx == -1) break;

matches.add(
SearchMatch(
filePath: path,
fileName: fileName,
lineNumber: i + 1,
columnNumber: idx + 1,
lineText: line,
matchStartColumn: idx + 1,
matchLength: query.length,
),
);
totalMatches++;
from = idx + (query.isEmpty ? 1 : query.length);
}
}
}

if (matches.isNotEmpty) {
results.add(
SearchFileResult(filePath: path, fileName: fileName, matches: matches),
);
}
}

return results;
}

/// Helper for incremental, cancellable search.
Stream<SearchFileResult> searchStream({
required FutureOr<Iterable<MapEntry<String, String>>> Function() loadAllContent,
required String query,
SearchOptions options = const SearchOptions(),
bool Function()? isCancelled,
}) async* {
if (query.isEmpty) return;

final entries = await loadAllContent();
if (isCancelled?.call() == true) throw SearchCancelled();

final results = searchInMemory(
contentByFilePath: Map<String, String>.fromEntries(entries),
query: query,
options: options,
);

for (final r in results) {
if (isCancelled?.call() == true) throw SearchCancelled();
yield r;
await Future<void>.delayed(Duration.zero);
}
}

String _basename(String path) {
final idx = path.lastIndexOf('/');
if (idx == -1) return path;
return path.substring(idx + 1);
}
}
Loading