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); Exit(T);
end; 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 } { Environment and process }
if SameText(FC.Name,'GetEnvVar') or SameText(FC.Name,'GetEnvironmentVariable') then if SameText(FC.Name,'GetEnvVar') or SameText(FC.Name,'GetEnvironmentVariable') then
begin begin

View file

@ -7238,6 +7238,16 @@ begin
Exit; Exit;
end; 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 if SameText(AExpr.Name, 'ForceDirectories') then
begin begin
if AExpr.Args.Count <> 1 then if AExpr.Args.Count <> 1 then

View file

@ -1593,6 +1593,7 @@ begin
Sym := TSymbol.Create('WriteFile', skProcedure, nil); Define(Sym); Sym := TSymbol.Create('WriteFile', skProcedure, nil); Define(Sym);
Sym := TSymbol.Create('AppendFile', skProcedure, nil); Define(Sym); Sym := TSymbol.Create('AppendFile', skProcedure, nil); Define(Sym);
Sym := TSymbol.Create('FileExists', skFunction, FTypeBoolean); 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('DeleteFile', skProcedure, nil); Define(Sym);
Sym := TSymbol.Create('CurrentExceptionMessage', skFunction, FTypeString); Define(Sym); Sym := TSymbol.Create('CurrentExceptionMessage', skFunction, FTypeString); Define(Sym);
{ Environment and process } { Environment and process }

View file

@ -47,6 +47,7 @@ type
procedure TestRun_BoolToStr_TrueAndFalse; procedure TestRun_BoolToStr_TrueAndFalse;
procedure TestRun_PlatformConstants; procedure TestRun_PlatformConstants;
procedure TestRun_GetCurrentDir_ReturnsNonEmpty; procedure TestRun_GetCurrentDir_ReturnsNonEmpty;
procedure TestRun_FileAge_ReturnsTimestamp;
end; end;
implementation implementation
@ -551,6 +552,17 @@ const
end. 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; procedure TE2ESysUtilsTests.TestRun_RenameFile_Works;
var var
Output: string; Output: string;
@ -676,6 +688,35 @@ begin
AssertEquals('GetCurrentDir non-empty', 'True', Trim(Output)); AssertEquals('GetCurrentDir non-empty', 'True', Trim(Output));
end; 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 initialization
RegisterTest(TE2ESysUtilsTests); RegisterTest(TE2ESysUtilsTests);

View file

@ -52,6 +52,8 @@ type
procedure TestCodegen_ReadFile_CallsRTL; procedure TestCodegen_ReadFile_CallsRTL;
procedure TestCodegen_WriteFile_CallsRTL; procedure TestCodegen_WriteFile_CallsRTL;
procedure TestCodegen_FileExists_CallsRTL; procedure TestCodegen_FileExists_CallsRTL;
procedure TestSemantic_FileAge_ReturnsInt64;
procedure TestCodegen_FileAge_CallsRTL;
{ ------------------------------------------------------------------ } { ------------------------------------------------------------------ }
{ GetEnvVar / Exec / Halt (Gap 2 — environment and process) } { GetEnvVar / Exec / Halt (Gap 2 — environment and process) }
@ -211,6 +213,15 @@ const
end. end.
'''; ''';
SrcFileAge =
'''
program P;
var A: Int64;
begin
A := FileAge('test.txt')
end.
''';
{ Gap 2: environment and process } { Gap 2: environment and process }
SrcGetEnvVar = SrcGetEnvVar =
''' '''
@ -501,6 +512,36 @@ begin
AssertTrue('FileExists calls _FileExists', Pos('_FileExists', IR) > 0); AssertTrue('FileExists calls _FileExists', Pos('_FileExists', IR) > 0);
end; 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 } { Gap 2: environment and process }
{ ------------------------------------------------------------------ } { ------------------------------------------------------------------ }

View file

@ -32,6 +32,7 @@ type
function ReadFile(const APath: string): string; virtual; abstract; function ReadFile(const APath: string): string; virtual; abstract;
procedure WriteFile(const APath, AContent: string); virtual; abstract; procedure WriteFile(const APath, AContent: string); virtual; abstract;
procedure AppendFile(const APath, AContent: string); virtual; abstract; procedure AppendFile(const APath, AContent: string); virtual; abstract;
function FileAge(const APath: string): Int64; virtual; abstract;
{ Directory operations } { Directory operations }
function DirectoryExists(const APath: string): Boolean; virtual; abstract; function DirectoryExists(const APath: string): Boolean; virtual; abstract;

View file

@ -40,6 +40,7 @@ type
function ReadFile(const APath: string): string; override; function ReadFile(const APath: string): string; override;
procedure WriteFile(const APath, AContent: string); override; procedure WriteFile(const APath, AContent: string); override;
procedure AppendFile(const APath, AContent: string); override; procedure AppendFile(const APath, AContent: string); override;
function FileAge(const APath: string): Int64; override;
{ Directory operations } { Directory operations }
function DirectoryExists(const APath: string): Boolean; override; function DirectoryExists(const APath: string): Boolean; override;
@ -220,6 +221,7 @@ function _RenameFile(OldPath, NewPath: Pointer): Integer;
function _ReadFile(Path: Pointer): Pointer; function _ReadFile(Path: Pointer): Pointer;
procedure _WriteFile(Path, Content: Pointer); procedure _WriteFile(Path, Content: Pointer);
procedure _AppendFile(Path, Content: Pointer); procedure _AppendFile(Path, Content: Pointer);
function _FileAge(Path: Pointer): Int64;
{ Directory operations } { Directory operations }
function _DirectoryExists(Path: Pointer): Integer; function _DirectoryExists(Path: Pointer): Integer;
@ -499,6 +501,14 @@ begin
libc_close(Fd); libc_close(Fd);
end; 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 } { TRtlPlatformPosix — Directory operations }
{ ================================================================== } { ================================================================== }
@ -1205,6 +1215,11 @@ begin
GRtlPlatform.AppendFile(string(PChar(Path)), string(PChar(Content))); GRtlPlatform.AppendFile(string(PChar(Path)), string(PChar(Content)));
end; end;
function _FileAge(Path: Pointer): Int64;
begin
Result := GRtlPlatform.FileAge(string(PChar(Path)));
end;
function _DirectoryExists(Path: Pointer): Integer; function _DirectoryExists(Path: Pointer): Integer;
begin begin
if GRtlPlatform.DirectoryExists(string(PChar(Path))) then Result := 1 else Result := 0; if GRtlPlatform.DirectoryExists(string(PChar(Path))) then Result := 1 else Result := 0;

View file

@ -15,6 +15,10 @@ uses Classes, Contnrs, StrUtils, DateUtils;
type type
TTaskStatus = (tsTodo, tsInProgress, tsDone); TTaskStatus = (tsTodo, tsInProgress, tsDone);
TIntHolder = class
FValue: Integer;
end;
TTask = class TTask = class
FId: Integer; FId: Integer;
FTitle: string; FTitle: string;
@ -32,9 +36,12 @@ type
FTasks: TObjectList; FTasks: TObjectList;
FNextId: Integer; FNextId: Integer;
FFilePath: string; FFilePath: string;
FLastMtime: Int64;
FDeletedIds: TObjectList;
function GetCount: Integer; function GetCount: Integer;
procedure ParseLine(const Line: string; CurrentStatus: TTaskStatus); procedure ParseLine(const Line: string; CurrentStatus: TTaskStatus);
function FormatTask(Task: TTask): string; function FormatTask(Task: TTask): string;
function IsDeleted(AId: Integer): Boolean;
public public
constructor Create(const AFilePath: string); constructor Create(const AFilePath: string);
destructor Destroy; override; destructor Destroy; override;
@ -50,6 +57,8 @@ type
function HasDetail(AId: Integer): Boolean; function HasDetail(AId: Integer): Boolean;
function LoadDetail(AId: Integer): string; function LoadDetail(AId: Integer): string;
procedure SaveDetail(AId: Integer; const AContent: string); procedure SaveDetail(AId: Integer; const AContent: string);
function HasExternalChanges: Boolean;
function MergeFromDisk: Integer;
property Count: Integer read GetCount; property Count: Integer read GetCount;
property FilePath: string read FFilePath; property FilePath: string read FFilePath;
end; end;
@ -63,11 +72,14 @@ begin
inherited Create; inherited Create;
FFilePath := AFilePath; FFilePath := AFilePath;
FTasks := TObjectList.Create(True); FTasks := TObjectList.Create(True);
FNextId := 1 FDeletedIds := TObjectList.Create(True);
FNextId := 1;
FLastMtime := -1
end; end;
destructor TBoard.Destroy; destructor TBoard.Destroy;
begin begin
FDeletedIds.Free;
FTasks.Free; FTasks.Free;
inherited Destroy inherited Destroy
end; end;
@ -209,7 +221,8 @@ begin
end end
finally finally
Lines.Free Lines.Free
end end;
FLastMtime := FileAge(FFilePath)
end; end;
procedure TBoard.Save; procedure TBoard.Save;
@ -218,6 +231,8 @@ var
I: Integer; I: Integer;
Task: TTask; Task: TTask;
begin begin
if Self.HasExternalChanges then
Self.MergeFromDisk;
Lines := TStringList.Create; Lines := TStringList.Create;
try try
Lines.Add('#next-id: ' + IntToStr(FNextId)); Lines.Add('#next-id: ' + IntToStr(FNextId));
@ -257,7 +272,8 @@ begin
Lines.SaveToFile(FFilePath) Lines.SaveToFile(FFilePath)
finally finally
Lines.Free Lines.Free
end end;
FLastMtime := FileAge(FFilePath)
end; end;
function TBoard.AddTask(const ATitle: string; AStatus: TTaskStatus): TTask; function TBoard.AddTask(const ATitle: string; AStatus: TTaskStatus): TTask;
@ -277,11 +293,26 @@ begin
FTasks.Add(Result) FTasks.Add(Result)
end; 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); procedure TBoard.DeleteTask(AId: Integer);
var var
I: Integer; I: Integer;
Task: TTask; Task: TTask;
DetailPath: string; DetailPath: string;
Holder: TIntHolder;
begin begin
I := 0; I := 0;
while I < FTasks.Count do while I < FTasks.Count do
@ -293,6 +324,9 @@ begin
if FileExists(DetailPath) then if FileExists(DetailPath) then
DeleteFile(DetailPath); DeleteFile(DetailPath);
FTasks.Delete(I); FTasks.Delete(I);
Holder := TIntHolder.Create;
Holder.FValue := AId;
FDeletedIds.Add(Holder);
Exit Exit
end; end;
I := I + 1 I := I + 1
@ -395,6 +429,73 @@ begin
WriteFile(Self.GetDetailPath(AId), AContent) WriteFile(Self.GetDetailPath(AId), AContent)
end; 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; function CreatedToLocalDate(const AUtcStr: string): string;
var var
UtcDT: TDateTime; UtcDT: TDateTime;

View file

@ -28,6 +28,7 @@ type
FInputPrompt: string; FInputPrompt: string;
FStatusMsg: string; FStatusMsg: string;
FNeedsRedraw: Boolean; FNeedsRedraw: Boolean;
FPollCounter: Integer;
procedure DrawBoard; procedure DrawBoard;
procedure DrawColumn(ColIndex: Integer; Row, Col, Width, Height: Integer); procedure DrawColumn(ColIndex: Integer; Row, Col, Width, Height: Integer);
procedure DrawStatusBar; procedure DrawStatusBar;
@ -64,7 +65,8 @@ begin
FInputBuf := ''; FInputBuf := '';
FInputPrompt := ''; FInputPrompt := '';
FStatusMsg := 'q:quit a:add d:delete Enter:details h/l:move task ?:help'; FStatusMsg := 'q:quit a:add d:delete Enter:details h/l:move task ?:help';
FNeedsRedraw := True FNeedsRedraw := True;
FPollCounter := 0
end; end;
function TKanbanUI.ColumnTitle(ColIndex: Integer): string; function TKanbanUI.ColumnTitle(ColIndex: Integer): string;
@ -343,6 +345,7 @@ begin
FTerm.ShowCursor; FTerm.ShowCursor;
FTerm.BufFlush; FTerm.BufFlush;
FTerm.DisableRawMode; FTerm.DisableRawMode;
FBoard.MergeFromDisk;
FBoard.Save; FBoard.Save;
Halt(0) Halt(0)
end; end;
@ -594,7 +597,7 @@ end;
procedure TKanbanUI.Run; procedure TKanbanUI.Run;
var var
Key: Integer; Key, Merged: Integer;
begin begin
FTerm.EnableRawMode; FTerm.EnableRawMode;
try try
@ -611,6 +614,27 @@ begin
Key := FTerm.ReadKey; 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 if FViewMode = vmBoard then
Self.HandleBoardKey(Key) Self.HandleBoardKey(Key)
else if FViewMode = vmDetail then else if FViewMode = vmDetail then