From 43f5e49d9ecc3fd1b16dfd531e082e918c285b1c Mon Sep 17 00:00:00 2001 From: Graeme Geldenhuys Date: Sat, 6 Jun 2026 11:59:53 +0100 Subject: [PATCH] feat(kanban): detect and merge external file changes in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- compiler/src/main/pascal/uCodeGenQBE.pas | 8 ++ compiler/src/main/pascal/uSemantic.pas | 10 ++ compiler/src/main/pascal/uSymbolTable.pas | 1 + .../src/test/pascal/cp.test.e2e.sysutils.pas | 41 +++++++ .../src/test/pascal/cp.test.selfhosting.pas | 41 +++++++ runtime/src/main/pascal/rtl.platform.pas | 1 + .../src/main/pascal/rtl.platform.posix.pas | 15 +++ tools/kanban/src/main/pascal/kanban.data.pas | 107 +++++++++++++++++- tools/kanban/src/main/pascal/kanban.ui.pas | 28 ++++- 9 files changed, 247 insertions(+), 5 deletions(-) diff --git a/compiler/src/main/pascal/uCodeGenQBE.pas b/compiler/src/main/pascal/uCodeGenQBE.pas index 47de193..bcdaaf4 100644 --- a/compiler/src/main/pascal/uCodeGenQBE.pas +++ b/compiler/src/main/pascal/uCodeGenQBE.pas @@ -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 diff --git a/compiler/src/main/pascal/uSemantic.pas b/compiler/src/main/pascal/uSemantic.pas index a0627f4..df9b267 100644 --- a/compiler/src/main/pascal/uSemantic.pas +++ b/compiler/src/main/pascal/uSemantic.pas @@ -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 diff --git a/compiler/src/main/pascal/uSymbolTable.pas b/compiler/src/main/pascal/uSymbolTable.pas index 2850661..0623bc2 100644 --- a/compiler/src/main/pascal/uSymbolTable.pas +++ b/compiler/src/main/pascal/uSymbolTable.pas @@ -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 } diff --git a/compiler/src/test/pascal/cp.test.e2e.sysutils.pas b/compiler/src/test/pascal/cp.test.e2e.sysutils.pas index 255ea8a..bf4591f 100644 --- a/compiler/src/test/pascal/cp.test.e2e.sysutils.pas +++ b/compiler/src/test/pascal/cp.test.e2e.sysutils.pas @@ -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); diff --git a/compiler/src/test/pascal/cp.test.selfhosting.pas b/compiler/src/test/pascal/cp.test.selfhosting.pas index ca2cb8f..29ec883 100644 --- a/compiler/src/test/pascal/cp.test.selfhosting.pas +++ b/compiler/src/test/pascal/cp.test.selfhosting.pas @@ -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 } { ------------------------------------------------------------------ } diff --git a/runtime/src/main/pascal/rtl.platform.pas b/runtime/src/main/pascal/rtl.platform.pas index 5d68202..670a7ce 100644 --- a/runtime/src/main/pascal/rtl.platform.pas +++ b/runtime/src/main/pascal/rtl.platform.pas @@ -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; diff --git a/runtime/src/main/pascal/rtl.platform.posix.pas b/runtime/src/main/pascal/rtl.platform.posix.pas index dfb01ad..f6ac5d4 100644 --- a/runtime/src/main/pascal/rtl.platform.posix.pas +++ b/runtime/src/main/pascal/rtl.platform.posix.pas @@ -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; diff --git a/tools/kanban/src/main/pascal/kanban.data.pas b/tools/kanban/src/main/pascal/kanban.data.pas index e9a479e..abf0401 100644 --- a/tools/kanban/src/main/pascal/kanban.data.pas +++ b/tools/kanban/src/main/pascal/kanban.data.pas @@ -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; diff --git a/tools/kanban/src/main/pascal/kanban.ui.pas b/tools/kanban/src/main/pascal/kanban.ui.pas index 029c338..4bd7e2c 100644 --- a/tools/kanban/src/main/pascal/kanban.ui.pas +++ b/tools/kanban/src/main/pascal/kanban.ui.pas @@ -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