Continue roadmap: local TLS and agent review preparation

This commit is contained in:
Hamza Ayed
2026-10-04 00:35:05 +03:00
parent 379098f3d6
commit d29275043a
28 changed files with 3226 additions and 47 deletions
@@ -48,6 +48,13 @@ class ChatCubit extends Cubit<ChatState> {
await _persistSettings(missingDefaults);
}
final savedModel = settings['selected_model'];
final savedWorkspacePaths =
(settings['saved_workspace_paths'] as List<dynamic>? ?? const [])
.whereType<String>()
.map((path) => path.trim())
.where((path) => path.isNotEmpty)
.toSet()
.toList(growable: false);
emit(
state.copyWith(
apiBaseUrl: _api.baseUrl,
@@ -60,6 +67,7 @@ class ChatCubit extends Cubit<ChatState> {
settings['dark_mode'] is bool
? settings['dark_mode']! as bool
: false,
savedWorkspacePaths: savedWorkspacePaths,
),
);
if (!_settingsStore.persistenceAvailable) {
@@ -265,7 +273,17 @@ class ChatCubit extends Cubit<ChatState> {
try {
final files = await _api.listWorkspaceFiles(path);
if (isClosed || state.selectedWorkspacePath != path) return const [];
emit(state.copyWith(availableWorkspaceFiles: files));
final savedPaths = [
...state.savedWorkspacePaths.where((savedPath) => savedPath != path),
path,
];
emit(
state.copyWith(
availableWorkspaceFiles: files,
savedWorkspacePaths: List.unmodifiable(savedPaths),
),
);
await _persistSettings({'saved_workspace_paths': savedPaths});
return files;
} catch (error) {
if (!isClosed) {
@@ -281,6 +299,13 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<bool> removeSavedWorkspace(String path) async {
final savedPaths =
state.savedWorkspacePaths.where((savedPath) => savedPath != path).toList();
emit(state.copyWith(savedWorkspacePaths: List.unmodifiable(savedPaths)));
return _persistSettings({'saved_workspace_paths': savedPaths});
}
void selectWorkspaceFiles(List<String> paths) {
if (state.isSending || paths.length > 3) return;
final available = state.availableWorkspaceFiles.toSet();
@@ -156,6 +156,7 @@ class ChatState {
this.selectedWorkspacePath,
this.availableWorkspaceFiles = const [],
this.selectedWorkspaceFiles = const [],
this.savedWorkspacePaths = const [],
this.isWebSearchMode = false,
this.error,
this.modelName = 'جارٍ اكتشاف النموذج…',
@@ -183,6 +184,7 @@ class ChatState {
final String? selectedWorkspacePath;
final List<String> availableWorkspaceFiles;
final List<String> selectedWorkspaceFiles;
final List<String> savedWorkspacePaths;
final bool isWebSearchMode;
final String? error;
final String modelName;
@@ -214,6 +216,7 @@ class ChatState {
bool clearSelectedWorkspacePath = false,
List<String>? availableWorkspaceFiles,
List<String>? selectedWorkspaceFiles,
List<String>? savedWorkspacePaths,
bool? isWebSearchMode,
String? error,
bool clearError = false,
@@ -256,6 +259,7 @@ class ChatState {
availableWorkspaceFiles ?? this.availableWorkspaceFiles,
selectedWorkspaceFiles:
selectedWorkspaceFiles ?? this.selectedWorkspaceFiles,
savedWorkspacePaths: savedWorkspacePaths ?? this.savedWorkspacePaths,
isWebSearchMode: isWebSearchMode ?? this.isWebSearchMode,
error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName,
@@ -104,9 +104,13 @@ class _ChatPageState extends State<ChatPage> {
context.read<ChatCubit>().toggleWorkspaceMode();
return;
}
await _addWorkspaceProject();
}
Future<void> _addWorkspaceProject() async {
try {
final directory = await getDirectoryPath(
confirmButtonText: 'اختيار مساحة العمل',
confirmButtonText: 'تسجيل هذا المشروع',
);
if (!mounted || directory == null) return;
final cubit = context.read<ChatCubit>();
@@ -116,13 +120,13 @@ class _ChatPageState extends State<ChatPage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'تعذر اختيار مساحة العمل: ${cubit.state.error ?? 'تعذر الاتصال بالخادم المحلي.'}',
'تعذر تسجيل المشروع: ${cubit.state.error ?? 'تعذر الاتصال بالخادم المحلي.'}',
),
),
);
return;
}
final name = directory.split(RegExp(r'[/\\]')).last;
final name = _workspaceName(directory);
if (files.isNotEmpty) {
final selectedFiles = await _showWorkspaceFiles(files);
if (!mounted) return;
@@ -132,19 +136,68 @@ class _ChatPageState extends State<ChatPage> {
SnackBar(
content: Text(
files.isEmpty
? 'تم اختيار مساحة العمل: $name. لم أجد ملفات نصية مدعومة.'
: 'تم اختيار مساحة العمل: $name',
? 'تم تسجيل المشروع: $name. لم أجد ملفات نصية مدعومة.'
: 'تم تسجيل المشروع وفتحه: $name',
),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('تعذر اختيار مجلد مساحة العمل: $error')),
SnackBar(content: Text('تعذر تسجيل مجلد المشروع: $error')),
);
}
}
String _workspaceName(String path) {
final parts = path.split(RegExp(r'[/\\]')).where((part) => part.isNotEmpty);
return parts.isEmpty ? path : parts.last;
}
Future<void> _openSavedWorkspace(String path) async {
final cubit = context.read<ChatCubit>();
await cubit.selectWorkspace(path);
if (!mounted) return;
if (cubit.state.selectedWorkspacePath != path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'تعذر فتح المشروع: ${cubit.state.error ?? 'المجلد غير متاح.'}',
),
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('تم فتح المشروع: ${_workspaceName(path)}')),
);
}
Future<void> _removeSavedWorkspace(String path) async {
final remove = await showDialog<bool>(
context: context,
builder:
(dialogContext) => AlertDialog(
title: const Text('إزالة المشروع من القائمة؟'),
content: Text(
'سيُزال ${_workspaceName(path)} من قائمة المشاريع المحفوظة فقط. لن تُحذف ملفاته.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('إلغاء'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('إزالة'),
),
],
),
);
if (!mounted || remove != true) return;
await context.read<ChatCubit>().removeSavedWorkspace(path);
}
Future<void> _indexSelectedWorkspaceKnowledge() async {
final cubit = context.read<ChatCubit>();
try {
@@ -1026,15 +1079,92 @@ class _ChatPageState extends State<ChatPage> {
),
),
const SizedBox(height: 24),
Row(
children: [
const Expanded(
child: Text(
'مشاريعي',
style: TextStyle(
color: Color(0xFF718078),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'تسجيل مشروع جديد',
visualDensity: VisualDensity.compact,
onPressed: chat.isSending ? null : _addWorkspaceProject,
icon: const Icon(Icons.create_new_folder_outlined, size: 19),
),
],
),
if (chat.savedWorkspacePaths.isEmpty)
TextButton.icon(
onPressed: chat.isSending ? null : _addWorkspaceProject,
icon: const Icon(Icons.add, size: 16),
label: const Text('تسجيل مشروع جديد'),
style: TextButton.styleFrom(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 148),
child: ListView.builder(
shrinkWrap: true,
itemCount: chat.savedWorkspacePaths.length,
itemBuilder: (context, index) {
final path = chat.savedWorkspacePaths[index];
final selected = chat.selectedWorkspacePath == path;
return Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Material(
color: selected
? const Color(0xFFDCEAE3)
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 3, right: 8),
leading: Icon(
Icons.folder_open_outlined,
size: 18,
color: selected ? _green : const Color(0xFF68766E),
),
title: Text(
_workspaceName(path),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: _ink),
),
trailing: IconButton(
tooltip: 'إزالة المشروع من القائمة',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 16),
onPressed: () => _removeSavedWorkspace(path),
),
onTap: chat.isSending
? null
: () => _openSavedWorkspace(path),
),
),
);
},
),
),
const SizedBox(height: 12),
const Text(
'مساحة العمل',
'محادثاتي',
style: TextStyle(
color: Color(0xFF718078),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
if (chat.isLoadingHistory)
const Padding(
padding: EdgeInsets.symmetric(vertical: 18),
@@ -180,6 +180,32 @@ void main() {
await cubit.close();
});
test('registered project folders persist and can be removed from the list', () async {
final settings = _MemorySettingsStore();
final firstApi = _VersionTestApi();
final first = ChatCubit(firstApi, settingsStore: settings);
await Future<void>.delayed(Duration.zero);
await first.selectWorkspace(r'C:\Projects\sovereign-ai');
expect(first.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']);
expect(settings.values['saved_workspace_paths'], [
r'C:\Projects\sovereign-ai',
]);
await first.close();
final restored = ChatCubit(
_VersionTestApi(),
settingsStore: settings,
);
await Future<void>.delayed(Duration.zero);
expect(restored.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']);
await restored.removeSavedWorkspace(r'C:\Projects\sovereign-ai');
expect(restored.state.savedWorkspacePaths, isEmpty);
expect(settings.values['saved_workspace_paths'], isEmpty);
await restored.close();
});
test('regeneration keeps prior answers and selection is persisted', () async {
final api = _VersionTestApi();
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
@@ -154,6 +154,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(find.text('مشاريعي'), findsOneWidget);
expect(find.byTooltip('تسجيل مشروع جديد'), findsOneWidget);
expect(tester.takeException(), isNull);
await chatCubit.setDarkMode(true);