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:
Graeme Geldenhuys 2026-06-06 11:59:53 +01:00
parent 34a1c4feab
commit 43f5e49d9e
9 changed files with 247 additions and 5 deletions

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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);

View file

@ -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 }
{ ------------------------------------------------------------------ }

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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