feat: create project folders from desktop app

This commit is contained in:
Hamza Ayed
2026-10-04 04:40:51 +03:00
parent ab34825faf
commit f6a2e8c3d3
7 changed files with 244 additions and 6 deletions
+1
View File
@@ -6,6 +6,7 @@
- 2026-10-04 — تسجيل المشاريع في الواجهة والـAPI: عند اختيار مجلد من Flutter يُسجّل API المحلي المسار في SQLite تحت هوية الحساب (`user_workspace_roots`) ثم يسرد الملفات المدعومة؛ عند إزالة المشروع يُلغى التسجيل ولا تُحذف الملفات. أضيف POST/DELETE لـ`/v1/agent/projects` ويُسمح بهما عبر loopback فقط. اختبارات API تثبت التسجيل والقراءة لصاحب المشروع، رفض الحساب الآخر، منع تسجيل مجلد متداخل بين حسابين أو مع جذر خصصه مسؤول لحساب آخر (409)، وإلغاء الوصول بعد الحذف أو بعد حذف المجلد الأصلي. اختبار حي على مجلد خارج جذر المنتج قرأ `README.md`، وسأل Gemma عبر الوكيل فأجاب من المحتوى باسم `Cedar`، ثم تحقق رفض الوصول بعد الإلغاء. اختبارات Python ذات الصلة 10/10 وFlutter 17/17، و`flutter analyze` بلا ملاحظات و`compileall` ناجح. أُعيد تشغيل API وفحص `/health` (`ok`, Gemma 4)، وتأكد وجود مساري POST/DELETE في OpenAPI، وبُني وشُغّل Windows Debug الحالي (PID 1328). تشغيل أوامر البناء/الاختبار من الوكيل لم يُدمج بعد؛ عزل نظام التشغيل لهذا التشغيل ما زال مفتوحًا.
- 2026-10-04 — إرشاد تسجيل المشروع من شاشة البداية: عند عدم فتح مساحة عمل تعرض الواجهة بطاقة تشرح أن المطلوب اختيار مجلد موجود وتوفّر زرًا مباشرًا؛ بعد فتحه تعرض اسم المشروع وقدرات الوكيل وحدوده، ومنها أن تشغيل أوامر البناء والاختبار غير متاح بعد. تسمية زر الشريط العلوي صارت «تسجيل مجلد مشروع موجود». اختبارات `widget_test.dart` نجحت 7/7 و`flutter analyze --no-pub` بلا ملاحظات. بُني Windows Debug من نسخة مستقلة في `%TEMP%` دون إغلاق النوافذ الموجودة؛ سجل MSBuild تحذيرًا أن Temp لا يُفضّل للبناء التزايدي، لكن البناء نجح خلال 359.7 ثانية. شُغلت النسخة (PID 44280، مقبض نافذة غير صفري ومستجيبة)، وأعاد API المحلي `/health` الحالة `ok` مع `gemma4:e2b` وSQLite والوكيل. هذه معاينة Debug وليست إصدارًا موزعًا.
- 2026-10-04 — إنشاء مجلد مشروع جديد على سطح المكتب: يختار المستخدم مجلدًا أبًا واسمًا، فيُنشأ مجلد فارغ ثم يُسجّل للوكيل؛ يمكن بعدها طلب إنشاء ملفات مع مراجعة diff وموافقة قبل الحفظ. قُيدت الأسماء لمنع المسارات والمجلدات المخفية والمستثناة وأسماء Windows المحجوزة، ورفض التصادم دون استبدال الموجود. الميزة متاحة على Windows/macOS/Linux الأصلي فقط؛ لا يظهر الزر على الويب وAndroid وiOS. اختبارات خدمة المجلد 3/3، مجموعة Flutter كاملة 22/22، واختبار الواجهة 7/7، و`flutter analyze --no-pub` بلا ملاحظات. بُني Windows Debug بنجاح خلال 352.1 ثانية إلى نسخة مستقلة في `%TEMP%`؛ لم تُشغّل النسخة لتجنب إضافة نافذة خامسة فوق نوافذ Debug المفتوحة. تشغيل أوامر البناء والاختبار داخل المشروع ما زال غير متاح لحين اكتمال العزل.
- اختبار سقف التخزين: نجح `scripts/verify_vhd_disk_quota.ps1` بقرص VHDX مؤقت 64MiB؛ كتب 53,477,376 بايت حتى `ERROR_DISK_FULL` (112)، بقي 151,552 بايت حرًا، وحُذف قرص الاختبار بعد التحقق. سكربت القرص الدائم 1GiB اجتاز تحليل PowerShell بعد إصلاحه، لكن `Status` أكد عدم وجود القرص؛ محاولات UAC عبر Windows PowerShell وPowerShell 7 و`cmd.exe` انتهت بخطأ بدء `0xc0000142` قبل أي تعديل. لذلك لا يوجد حتى الآن VHDX دائم، ولا يُسمح للوكيل بتنفيذ أوامر.
@@ -0,0 +1,63 @@
import 'dart:io';
bool get canCreateProjectFolders =>
Platform.isWindows || Platform.isMacOS || Platform.isLinux;
Future<String> createProjectFolder(
String parentPath,
String projectName,
) async {
if (!canCreateProjectFolders) {
throw UnsupportedError(
'إنشاء مجلدات المشاريع متاح حاليًا على سطح المكتب فقط.',
);
}
final name = _validatedProjectName(projectName);
final parent = Directory(parentPath);
if (!await parent.exists()) {
throw ArgumentError('المجلد الأب غير موجود أو لا يمكن الوصول إليه.');
}
final resolvedParent = await parent.resolveSymbolicLinks();
final separator = Platform.pathSeparator;
final targetPath =
resolvedParent.endsWith(separator)
? '$resolvedParent$name'
: '$resolvedParent$separator$name';
if (await FileSystemEntity.type(targetPath, followLinks: false) !=
FileSystemEntityType.notFound) {
throw ArgumentError('يوجد ملف أو مجلد بالاسم نفسه في هذا الموقع.');
}
await Directory(targetPath).create();
return await Directory(targetPath).resolveSymbolicLinks();
}
String _validatedProjectName(String value) {
final name = value.trim();
if (name.isEmpty || name.length > 80) {
throw ArgumentError('اكتب اسمًا للمشروع من حرف إلى 80 حرفًا.');
}
if (name.startsWith('.') || name == '.' || name == '..') {
throw ArgumentError('لا يمكن أن يبدأ اسم المشروع بنقطة.');
}
if (RegExp(r'[<>:"/\\|?*\x00-\x1F]').hasMatch(name) ||
name.endsWith('.') ||
name.endsWith(' ')) {
throw ArgumentError('الاسم يحتوي محارف لا يسمح بها نظام الملفات.');
}
if (RegExp(
r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$',
caseSensitive: false,
).hasMatch(name)) {
throw ArgumentError('هذا اسم محجوز في Windows. اختر اسمًا آخر.');
}
if ({
'.git',
'.venv',
'node_modules',
'build',
'__pycache__',
}.contains(name.toLowerCase())) {
throw ArgumentError('هذا الاسم مخصص لمجلد مستثنى من مساحة العمل.');
}
return name;
}
@@ -0,0 +1,8 @@
import 'project_folder_stub.dart'
if (dart.library.io) 'project_folder_io.dart'
as platform;
bool get canCreateProjectFolders => platform.canCreateProjectFolders;
Future<String> createProjectFolder(String parentPath, String projectName) =>
platform.createProjectFolder(parentPath, projectName);
@@ -0,0 +1,5 @@
bool get canCreateProjectFolders => false;
Future<String> createProjectFolder(String parentPath, String projectName) {
throw UnsupportedError('إنشاء مجلد مشروع غير مدعوم على هذه المنصة.');
}
@@ -12,6 +12,7 @@ import '../../../audio/presentation/cubit/voice_cubit.dart';
import '../../../audio/presentation/cubit/voice_state.dart';
import '../../../../core/notifications/completion_notification_service.dart';
import '../../../../core/network/api_repository.dart';
import '../../../../core/projects/project_folder_service.dart';
import '../cubit/chat_cubit.dart';
import '../cubit/chat_state.dart';
@@ -149,6 +150,85 @@ class _ChatPageState extends State<ChatPage> {
}
}
Future<void> _createNewProject() async {
final cubit = context.read<ChatCubit>();
String? createdPath;
try {
final parent = await getDirectoryPath(
confirmButtonText: 'اختيار موقع المشروع',
);
if (!mounted || parent == null) return;
final nameController = TextEditingController();
final name = await showDialog<String>(
context: context,
builder:
(dialogContext) => AlertDialog(
title: const Text('إنشاء مشروع جديد'),
content: TextField(
controller: nameController,
autofocus: true,
maxLength: 80,
decoration: const InputDecoration(
labelText: 'اسم المشروع',
hintText: 'مثال: my-first-app',
),
onSubmitted:
(value) => Navigator.of(dialogContext).pop(value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('إلغاء'),
),
FilledButton(
onPressed:
() => Navigator.of(
dialogContext,
).pop(nameController.text.trim()),
child: const Text('إنشاء المجلد'),
),
],
),
);
nameController.dispose();
if (!mounted || name == null) return;
createdPath = await createProjectFolder(parent, name);
final files = await cubit.selectWorkspace(createdPath);
if (!mounted) return;
if (cubit.state.selectedWorkspacePath != createdPath) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'تم إنشاء المجلد لكنه لم يُسجّل للوكيل: ${cubit.state.error ?? createdPath}',
),
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'أنشأت مجلد المشروع وسجّلته: ${_workspaceName(createdPath)}. يمكنك الآن طلب إنشاء ملفات، وستراجع كل معاينة قبل حفظها.',
),
),
);
if (files.isNotEmpty) await _showWorkspaceFiles(files);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
createdPath == null
? 'تعذر إنشاء مجلد المشروع: $error'
: 'تم إنشاء المجلد ولم يكتمل تسجيله: $createdPath · $error',
),
),
);
}
}
String _workspaceName(String path) {
final parts = path.split(RegExp(r'[/\\]')).where((part) => part.isNotEmpty);
return parts.isEmpty ? path : parts.last;
@@ -1844,11 +1924,26 @@ class _ChatPageState extends State<ChatPage> {
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed:
chat.isSending ? null : _addWorkspaceProject,
icon: const Icon(Icons.folder_open_outlined),
label: const Text('اختيار مجلد مشروع موجود'),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed:
chat.isSending ? null : _addWorkspaceProject,
icon: const Icon(Icons.folder_open_outlined),
label: const Text('فتح مجلد مشروع موجود'),
),
if (canCreateProjectFolders)
OutlinedButton.icon(
onPressed:
chat.isSending ? null : _createNewProject,
icon: const Icon(
Icons.create_new_folder_outlined,
),
label: const Text('إنشاء مشروع جديد'),
),
],
),
],
),
@@ -0,0 +1,65 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/projects/project_folder_service.dart';
void main() {
test('creates a new project folder under the chosen parent', () async {
final parent = await Directory.systemTemp.createTemp(
'mithqal-project-parent-',
);
addTearDown(() => parent.delete(recursive: true));
final createdPath = await createProjectFolder(parent.path, 'تجربة أولى');
expect(await Directory(createdPath).exists(), isTrue);
expect(
Directory(createdPath).parent.resolveSymbolicLinksSync(),
parent.path,
);
});
test('rejects unsafe, hidden, reserved, and empty project names', () async {
final parent = await Directory.systemTemp.createTemp(
'mithqal-project-invalid-',
);
addTearDown(() => parent.delete(recursive: true));
for (final name in [
'',
'.',
'..',
'.hidden',
r'a\b',
'CON',
'node_modules',
]) {
await expectLater(
createProjectFolder(parent.path, name),
throwsArgumentError,
);
}
expect(await parent.list().isEmpty, isTrue);
});
test('does not overwrite an existing file or directory', () async {
final parent = await Directory.systemTemp.createTemp(
'mithqal-project-collision-',
);
addTearDown(() => parent.delete(recursive: true));
await Directory(
'${parent.path}${Platform.pathSeparator}already-there',
).create();
await expectLater(
createProjectFolder(parent.path, 'already-there'),
throwsArgumentError,
);
expect(
await Directory(
'${parent.path}${Platform.pathSeparator}already-there',
).exists(),
isTrue,
);
});
}
@@ -170,7 +170,8 @@ void main() {
expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(find.text('مشاريعي'), findsOneWidget);
expect(find.text('جرّب الوكيل على مشروعك'), findsOneWidget);
expect(find.text('اختيار مجلد مشروع موجود'), findsOneWidget);
expect(find.text('فتح مجلد مشروع موجود'), findsOneWidget);
expect(find.text('إنشاء مشروع جديد'), findsOneWidget);
expect(find.byTooltip('إضافة مشروع موجود إلى القائمة'), findsOneWidget);
expect(
find.byTooltip('تسجيل صوت؛ يُرسل إلى Groq السحابي لتحويله إلى نص'),