feat(kanban): detect and merge external file changes in TUI
Add file-change monitoring to the kanban TUI so that tasks added via CLI (or any external process) while the TUI is running are automatically merged into the live view instead of being silently overwritten on save. The implementation has three layers: 1. New FileAge built-in (compiler + runtime): returns the file's mtime as Int64 via stat(), or -1 if the file does not exist. Follows the same pattern as FileExists — symbol table registration, semantic analysis, codegen, and POSIX ABI stub. 2. Merge-aware TBoard (kanban.data): tracks last-known mtime, deleted IDs (so re-reads don't resurrect user-deleted tasks), and provides HasExternalChanges/MergeFromDisk. Save() now auto-merges before writing. ID conflicts (same ID, different task) are resolved by reassigning the in-memory task to a fresh ID. 3. Idle polling in TKanbanUI.Run: every ~2 seconds (20 idle ReadKey cycles at VTIME=1), checks HasExternalChanges and merges new tasks with a status bar notification.
This commit is contained in:
parent
34a1c4feab
commit
43f5e49d9e
|
|
@ -8043,6 +8043,14 @@ begin
|
|||
Exit(T);
|
||||
end;
|
||||
|
||||
if SameText(FC.Name,'FileAge') then
|
||||
begin
|
||||
L := EmitExpr(TASTExpr(FC.Args.Items[0]));
|
||||
T := AllocTemp;
|
||||
EmitLine(Format(' %s =l call $_FileAge(l %s)', [T, L]));
|
||||
Exit(T);
|
||||
end;
|
||||
|
||||
{ Environment and process }
|
||||
if SameText(FC.Name,'GetEnvVar') or SameText(FC.Name,'GetEnvironmentVariable') then
|
||||
begin
|
||||
|
|
|
|||
|
|
@ -7238,6 +7238,16 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
if SameText(AExpr.Name, 'FileAge') then
|
||||
begin
|
||||
if AExpr.Args.Count <> 1 then
|
||||
SemanticError('FileAge requires exactly 1 argument', AExpr.Line, AExpr.Col);
|
||||
AnalyseExpr(TASTExpr(AExpr.Args.Items[0]));
|
||||
Result := FTable.TypeInt64;
|
||||
AExpr.ResolvedType := Result;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if SameText(AExpr.Name, 'ForceDirectories') then
|
||||
begin
|
||||
if AExpr.Args.Count <> 1 then
|
||||
|
|
|
|||
|
|
@ -1593,6 +1593,7 @@ begin
|
|||
Sym := TSymbol.Create('WriteFile', skProcedure, nil); Define(Sym);
|
||||
Sym := TSymbol.Create('AppendFile', skProcedure, nil); Define(Sym);
|
||||
Sym := TSymbol.Create('FileExists', skFunction, FTypeBoolean); Define(Sym);
|
||||
Sym := TSymbol.Create('FileAge', skFunction, FTypeInt64); Define(Sym);
|
||||
Sym := TSymbol.Create('DeleteFile', skProcedure, nil); Define(Sym);
|
||||
Sym := TSymbol.Create('CurrentExceptionMessage', skFunction, FTypeString); Define(Sym);
|
||||
{ Environment and process }
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ type
|
|||
procedure TestRun_BoolToStr_TrueAndFalse;
|
||||
procedure TestRun_PlatformConstants;
|
||||
procedure TestRun_GetCurrentDir_ReturnsNonEmpty;
|
||||
procedure TestRun_FileAge_ReturnsTimestamp;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
|
@ -551,6 +552,17 @@ const
|
|||
end.
|
||||
''';
|
||||
|
||||
SrcFileAge =
|
||||
'''
|
||||
program P;
|
||||
var Age: Int64;
|
||||
begin
|
||||
Age := FileAge(ParamStr(1));
|
||||
WriteLn(Age > 0);
|
||||
WriteLn(FileAge('__no_such_file_xyz__'))
|
||||
end.
|
||||
''';
|
||||
|
||||
procedure TE2ESysUtilsTests.TestRun_RenameFile_Works;
|
||||
var
|
||||
Output: string;
|
||||
|
|
@ -676,6 +688,35 @@ begin
|
|||
AssertEquals('GetCurrentDir non-empty', 'True', Trim(Output));
|
||||
end;
|
||||
|
||||
procedure TE2ESysUtilsTests.TestRun_FileAge_ReturnsTimestamp;
|
||||
var
|
||||
Output: string;
|
||||
RCode: Integer;
|
||||
TmpFile: string;
|
||||
Lines: TStringList;
|
||||
begin
|
||||
if not ToolchainAvailable then begin Ignore('toolchain unavailable'); Exit; end;
|
||||
TmpFile := GetTempFileName('', 'blaise_fileage_test');
|
||||
Lines := TStringList.Create;
|
||||
Lines.Add('x');
|
||||
Lines.SaveToFile(TmpFile);
|
||||
Lines.Free;
|
||||
try
|
||||
AssertTrue('compile+run',
|
||||
CompileAndRun(SrcFileAge, Output, RCode, [TmpFile]));
|
||||
Lines := TStringList.Create;
|
||||
try
|
||||
Lines.Text := Trim(Output);
|
||||
AssertEquals('existing file age > 0', 'True', Lines.Strings[0]);
|
||||
AssertEquals('missing file age = -1', '-1', Lines.Strings[1]);
|
||||
finally
|
||||
Lines.Free;
|
||||
end;
|
||||
finally
|
||||
if FileExists(TmpFile) then DeleteFile(TmpFile);
|
||||
end;
|
||||
end;
|
||||
|
||||
initialization
|
||||
RegisterTest(TE2ESysUtilsTests);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ type
|
|||
procedure TestCodegen_ReadFile_CallsRTL;
|
||||
procedure TestCodegen_WriteFile_CallsRTL;
|
||||
procedure TestCodegen_FileExists_CallsRTL;
|
||||
procedure TestSemantic_FileAge_ReturnsInt64;
|
||||
procedure TestCodegen_FileAge_CallsRTL;
|
||||
|
||||
{ ------------------------------------------------------------------ }
|
||||
{ GetEnvVar / Exec / Halt (Gap 2 — environment and process) }
|
||||
|
|
@ -211,6 +213,15 @@ const
|
|||
end.
|
||||
''';
|
||||
|
||||
SrcFileAge =
|
||||
'''
|
||||
program P;
|
||||
var A: Int64;
|
||||
begin
|
||||
A := FileAge('test.txt')
|
||||
end.
|
||||
''';
|
||||
|
||||
{ Gap 2: environment and process }
|
||||
SrcGetEnvVar =
|
||||
'''
|
||||
|
|
@ -501,6 +512,36 @@ begin
|
|||
AssertTrue('FileExists calls _FileExists', Pos('_FileExists', IR) > 0);
|
||||
end;
|
||||
|
||||
procedure TSelfHostingTests.TestSemantic_FileAge_ReturnsInt64;
|
||||
var
|
||||
Lex: TLexer;
|
||||
Par: TParser;
|
||||
SA: TSemanticAnalyser;
|
||||
Prog: TProgram;
|
||||
Ass: TAssignment;
|
||||
begin
|
||||
Lex := TLexer.Create(SrcFileAge);
|
||||
Par := TParser.Create(Lex);
|
||||
Prog := Par.Parse;
|
||||
Par.Free;
|
||||
Lex.Free;
|
||||
SA := TSemanticAnalyser.Create;
|
||||
SA.Analyse(Prog);
|
||||
SA.Free;
|
||||
Ass := TAssignment(Prog.Block.Stmts[0]);
|
||||
AssertEquals('FileAge returns Int64',
|
||||
Ord(tyInt64), Ord(Ass.Expr.ResolvedType.Kind));
|
||||
Prog.Free;
|
||||
end;
|
||||
|
||||
procedure TSelfHostingTests.TestCodegen_FileAge_CallsRTL;
|
||||
var
|
||||
IR: string;
|
||||
begin
|
||||
IR := GenIR(SrcFileAge);
|
||||
AssertTrue('FileAge calls _FileAge', Pos('_FileAge', IR) > 0);
|
||||
end;
|
||||
|
||||
{ ------------------------------------------------------------------ }
|
||||
{ Gap 2: environment and process }
|
||||
{ ------------------------------------------------------------------ }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type
|
|||
function ReadFile(const APath: string): string; virtual; abstract;
|
||||
procedure WriteFile(const APath, AContent: string); virtual; abstract;
|
||||
procedure AppendFile(const APath, AContent: string); virtual; abstract;
|
||||
function FileAge(const APath: string): Int64; virtual; abstract;
|
||||
|
||||
{ Directory operations }
|
||||
function DirectoryExists(const APath: string): Boolean; virtual; abstract;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type
|
|||
function ReadFile(const APath: string): string; override;
|
||||
procedure WriteFile(const APath, AContent: string); override;
|
||||
procedure AppendFile(const APath, AContent: string); override;
|
||||
function FileAge(const APath: string): Int64; override;
|
||||
|
||||
{ Directory operations }
|
||||
function DirectoryExists(const APath: string): Boolean; override;
|
||||
|
|
@ -220,6 +221,7 @@ function _RenameFile(OldPath, NewPath: Pointer): Integer;
|
|||
function _ReadFile(Path: Pointer): Pointer;
|
||||
procedure _WriteFile(Path, Content: Pointer);
|
||||
procedure _AppendFile(Path, Content: Pointer);
|
||||
function _FileAge(Path: Pointer): Int64;
|
||||
|
||||
{ Directory operations }
|
||||
function _DirectoryExists(Path: Pointer): Integer;
|
||||
|
|
@ -499,6 +501,14 @@ begin
|
|||
libc_close(Fd);
|
||||
end;
|
||||
|
||||
function TRtlPlatformPosix.FileAge(const APath: string): Int64;
|
||||
var
|
||||
St: TStatBuf;
|
||||
begin
|
||||
if libc_stat(StrData(Pointer(APath)), @St) <> 0 then begin Result := -1; Exit end;
|
||||
Result := St.Mtime;
|
||||
end;
|
||||
|
||||
{ ================================================================== }
|
||||
{ TRtlPlatformPosix — Directory operations }
|
||||
{ ================================================================== }
|
||||
|
|
@ -1205,6 +1215,11 @@ begin
|
|||
GRtlPlatform.AppendFile(string(PChar(Path)), string(PChar(Content)));
|
||||
end;
|
||||
|
||||
function _FileAge(Path: Pointer): Int64;
|
||||
begin
|
||||
Result := GRtlPlatform.FileAge(string(PChar(Path)));
|
||||
end;
|
||||
|
||||
function _DirectoryExists(Path: Pointer): Integer;
|
||||
begin
|
||||
if GRtlPlatform.DirectoryExists(string(PChar(Path))) then Result := 1 else Result := 0;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ uses Classes, Contnrs, StrUtils, DateUtils;
|
|||
type
|
||||
TTaskStatus = (tsTodo, tsInProgress, tsDone);
|
||||
|
||||
TIntHolder = class
|
||||
FValue: Integer;
|
||||
end;
|
||||
|
||||
TTask = class
|
||||
FId: Integer;
|
||||
FTitle: string;
|
||||
|
|
@ -32,9 +36,12 @@ type
|
|||
FTasks: TObjectList;
|
||||
FNextId: Integer;
|
||||
FFilePath: string;
|
||||
FLastMtime: Int64;
|
||||
FDeletedIds: TObjectList;
|
||||
function GetCount: Integer;
|
||||
procedure ParseLine(const Line: string; CurrentStatus: TTaskStatus);
|
||||
function FormatTask(Task: TTask): string;
|
||||
function IsDeleted(AId: Integer): Boolean;
|
||||
public
|
||||
constructor Create(const AFilePath: string);
|
||||
destructor Destroy; override;
|
||||
|
|
@ -50,6 +57,8 @@ type
|
|||
function HasDetail(AId: Integer): Boolean;
|
||||
function LoadDetail(AId: Integer): string;
|
||||
procedure SaveDetail(AId: Integer; const AContent: string);
|
||||
function HasExternalChanges: Boolean;
|
||||
function MergeFromDisk: Integer;
|
||||
property Count: Integer read GetCount;
|
||||
property FilePath: string read FFilePath;
|
||||
end;
|
||||
|
|
@ -63,11 +72,14 @@ begin
|
|||
inherited Create;
|
||||
FFilePath := AFilePath;
|
||||
FTasks := TObjectList.Create(True);
|
||||
FNextId := 1
|
||||
FDeletedIds := TObjectList.Create(True);
|
||||
FNextId := 1;
|
||||
FLastMtime := -1
|
||||
end;
|
||||
|
||||
destructor TBoard.Destroy;
|
||||
begin
|
||||
FDeletedIds.Free;
|
||||
FTasks.Free;
|
||||
inherited Destroy
|
||||
end;
|
||||
|
|
@ -209,7 +221,8 @@ begin
|
|||
end
|
||||
finally
|
||||
Lines.Free
|
||||
end
|
||||
end;
|
||||
FLastMtime := FileAge(FFilePath)
|
||||
end;
|
||||
|
||||
procedure TBoard.Save;
|
||||
|
|
@ -218,6 +231,8 @@ var
|
|||
I: Integer;
|
||||
Task: TTask;
|
||||
begin
|
||||
if Self.HasExternalChanges then
|
||||
Self.MergeFromDisk;
|
||||
Lines := TStringList.Create;
|
||||
try
|
||||
Lines.Add('#next-id: ' + IntToStr(FNextId));
|
||||
|
|
@ -257,7 +272,8 @@ begin
|
|||
Lines.SaveToFile(FFilePath)
|
||||
finally
|
||||
Lines.Free
|
||||
end
|
||||
end;
|
||||
FLastMtime := FileAge(FFilePath)
|
||||
end;
|
||||
|
||||
function TBoard.AddTask(const ATitle: string; AStatus: TTaskStatus): TTask;
|
||||
|
|
@ -277,11 +293,26 @@ begin
|
|||
FTasks.Add(Result)
|
||||
end;
|
||||
|
||||
function TBoard.IsDeleted(AId: Integer): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
I := 0;
|
||||
while I < FDeletedIds.Count do
|
||||
begin
|
||||
if TIntHolder(FDeletedIds.Get(I)).FValue = AId then
|
||||
Exit(True);
|
||||
I := I + 1
|
||||
end;
|
||||
Result := False
|
||||
end;
|
||||
|
||||
procedure TBoard.DeleteTask(AId: Integer);
|
||||
var
|
||||
I: Integer;
|
||||
Task: TTask;
|
||||
DetailPath: string;
|
||||
Holder: TIntHolder;
|
||||
begin
|
||||
I := 0;
|
||||
while I < FTasks.Count do
|
||||
|
|
@ -293,6 +324,9 @@ begin
|
|||
if FileExists(DetailPath) then
|
||||
DeleteFile(DetailPath);
|
||||
FTasks.Delete(I);
|
||||
Holder := TIntHolder.Create;
|
||||
Holder.FValue := AId;
|
||||
FDeletedIds.Add(Holder);
|
||||
Exit
|
||||
end;
|
||||
I := I + 1
|
||||
|
|
@ -395,6 +429,73 @@ begin
|
|||
WriteFile(Self.GetDetailPath(AId), AContent)
|
||||
end;
|
||||
|
||||
function TBoard.HasExternalChanges: Boolean;
|
||||
var
|
||||
CurrentMtime: Int64;
|
||||
begin
|
||||
CurrentMtime := FileAge(FFilePath);
|
||||
Result := (CurrentMtime <> -1) and (CurrentMtime <> FLastMtime)
|
||||
end;
|
||||
|
||||
function TBoard.MergeFromDisk: Integer;
|
||||
var
|
||||
DiskBoard: TBoard;
|
||||
I, Merged, MaxId: Integer;
|
||||
DiskTask, MemTask, NewTask: TTask;
|
||||
begin
|
||||
Merged := 0;
|
||||
DiskBoard := TBoard.Create(FFilePath);
|
||||
try
|
||||
DiskBoard.Load;
|
||||
|
||||
MaxId := FNextId;
|
||||
if DiskBoard.FNextId > MaxId then
|
||||
MaxId := DiskBoard.FNextId;
|
||||
|
||||
I := 0;
|
||||
while I < DiskBoard.FTasks.Count do
|
||||
begin
|
||||
DiskTask := TTask(DiskBoard.FTasks.Get(I));
|
||||
if Self.IsDeleted(DiskTask.FId) then
|
||||
begin
|
||||
I := I + 1;
|
||||
Continue
|
||||
end;
|
||||
MemTask := Self.FindById(DiskTask.FId);
|
||||
if MemTask = nil then
|
||||
begin
|
||||
NewTask := TTask.Create;
|
||||
NewTask.FId := DiskTask.FId;
|
||||
NewTask.FTitle := DiskTask.FTitle;
|
||||
NewTask.FStatus := DiskTask.FStatus;
|
||||
NewTask.FCreated := DiskTask.FCreated;
|
||||
NewTask.FPriority := DiskTask.FPriority;
|
||||
FTasks.Add(NewTask);
|
||||
Merged := Merged + 1
|
||||
end
|
||||
else if MemTask.FTitle <> DiskTask.FTitle then
|
||||
begin
|
||||
MemTask.FId := MaxId;
|
||||
MaxId := MaxId + 1;
|
||||
NewTask := TTask.Create;
|
||||
NewTask.FId := DiskTask.FId;
|
||||
NewTask.FTitle := DiskTask.FTitle;
|
||||
NewTask.FStatus := DiskTask.FStatus;
|
||||
NewTask.FCreated := DiskTask.FCreated;
|
||||
NewTask.FPriority := DiskTask.FPriority;
|
||||
FTasks.Add(NewTask);
|
||||
Merged := Merged + 1
|
||||
end;
|
||||
I := I + 1
|
||||
end;
|
||||
FNextId := MaxId
|
||||
finally
|
||||
DiskBoard.Free
|
||||
end;
|
||||
FLastMtime := FileAge(FFilePath);
|
||||
Result := Merged
|
||||
end;
|
||||
|
||||
function CreatedToLocalDate(const AUtcStr: string): string;
|
||||
var
|
||||
UtcDT: TDateTime;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type
|
|||
FInputPrompt: string;
|
||||
FStatusMsg: string;
|
||||
FNeedsRedraw: Boolean;
|
||||
FPollCounter: Integer;
|
||||
procedure DrawBoard;
|
||||
procedure DrawColumn(ColIndex: Integer; Row, Col, Width, Height: Integer);
|
||||
procedure DrawStatusBar;
|
||||
|
|
@ -64,7 +65,8 @@ begin
|
|||
FInputBuf := '';
|
||||
FInputPrompt := '';
|
||||
FStatusMsg := 'q:quit a:add d:delete Enter:details h/l:move task ?:help';
|
||||
FNeedsRedraw := True
|
||||
FNeedsRedraw := True;
|
||||
FPollCounter := 0
|
||||
end;
|
||||
|
||||
function TKanbanUI.ColumnTitle(ColIndex: Integer): string;
|
||||
|
|
@ -343,6 +345,7 @@ begin
|
|||
FTerm.ShowCursor;
|
||||
FTerm.BufFlush;
|
||||
FTerm.DisableRawMode;
|
||||
FBoard.MergeFromDisk;
|
||||
FBoard.Save;
|
||||
Halt(0)
|
||||
end;
|
||||
|
|
@ -594,7 +597,7 @@ end;
|
|||
|
||||
procedure TKanbanUI.Run;
|
||||
var
|
||||
Key: Integer;
|
||||
Key, Merged: Integer;
|
||||
begin
|
||||
FTerm.EnableRawMode;
|
||||
try
|
||||
|
|
@ -611,6 +614,27 @@ begin
|
|||
|
||||
Key := FTerm.ReadKey;
|
||||
|
||||
if Key = KEY_NONE then
|
||||
begin
|
||||
FPollCounter := FPollCounter + 1;
|
||||
if FPollCounter >= 20 then
|
||||
begin
|
||||
FPollCounter := 0;
|
||||
if FBoard.HasExternalChanges then
|
||||
begin
|
||||
Merged := FBoard.MergeFromDisk;
|
||||
if Merged > 0 then
|
||||
begin
|
||||
FStatusMsg := 'Reloaded ' + IntToStr(Merged) + ' new task(s) from disk';
|
||||
Self.ClampRow;
|
||||
FNeedsRedraw := True
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
FPollCounter := 0;
|
||||
|
||||
if FViewMode = vmBoard then
|
||||
Self.HandleBoardKey(Key)
|
||||
else if FViewMode = vmDetail then
|
||||
|
|
|
|||
Loading…
Reference in a new issue